From c17bcbc72a403bb3d84cadcc390f1c4d4b55d1df 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 1/4] 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 chore: organize test fixtures into subdirectories 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 chore: organize test fixtures into subdirectories --- .agents/skills/naftiko-capability/SKILL.md | 30 +- .../references/design-guidelines.md | 27 ++ .../references/wrap-api-as-mcp.md | 16 +- AGENTS.md | 5 + README.md | 1 + 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 +++ src/main/resources/wiki/Home.md | 1 + .../Specification-\342\200\220-Schema.md" | 198 +++++++- .../engine/AggregateRefResolverTest.java | 446 ++++++++++++++++++ .../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 | 211 +++++++++ .../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 | 64 +++ .../resources/aggregates/aggregate-basic.yaml | 79 ++++ .../aggregates/aggregate-hints-override.yaml | 86 ++++ .../aggregates/aggregate-invalid-ref.yaml | 43 ++ .../{ => 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 .../rules/spectral-semantics-consistent.yaml | 60 +++ .../spectral-semantics-inconsistent.yaml | 60 +++ 61 files changed, 2415 insertions(+), 46 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/aggregates/aggregate-basic.yaml create mode 100644 src/test/resources/aggregates/aggregate-hints-override.yaml create mode 100644 src/test/resources/aggregates/aggregate-invalid-ref.yaml 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%) create mode 100644 src/test/resources/rules/spectral-semantics-consistent.yaml create mode 100644 src/test/resources/rules/spectral-semantics-inconsistent.yaml diff --git a/.agents/skills/naftiko-capability/SKILL.md b/.agents/skills/naftiko-capability/SKILL.md index cc257d20..475b42c8 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 prototype a tool or endpoint before the backend exists" or "I want to return static or dynamic mock data" | Read `references/mock-capability.md` | | "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 | @@ -121,13 +123,15 @@ before writing any mock output parameters. replace `value` with `mapping`, and add `call` or `steps` — the exposed contract does not change. 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 @@ -141,9 +145,19 @@ before writing any mock output parameters. 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. -17. In mock mode, use `value` on output parameters — never `const`. +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. +21. In mock mode, use `value` on output parameters — never `const`. `const` is a JSON Schema keyword retained for validation and linting only; it has no effect at runtime. -18. In mock mode, Mustache templates in `value` fields resolve only against +22. In mock mode, Mustache templates in `value` fields resolve only against top-level input parameter names. Do not reference `with`-remapped consumed parameter names — those are not in scope for output resolution. \ 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 e9c297d2..dc06ce67 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/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/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: + *

+ */ + 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 c8894095..6eda1791 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -706,6 +706,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": [ @@ -728,6 +736,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", @@ -882,6 +1021,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}.", @@ -911,13 +1055,12 @@ "type": "array" } }, - "required": [ - "name", - "description" - ], + "required": [], "anyOf": [ { "required": [ + "name", + "description", "call" ], "type": "object", @@ -932,6 +1075,8 @@ }, { "required": [ + "name", + "description", "steps" ], "type": "object", @@ -944,6 +1089,11 @@ } } } + }, + { + "required": [ + "ref" + ] } ], "additionalProperties": false @@ -1339,6 +1489,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", @@ -1391,6 +1546,11 @@ } } } + }, + { + "required": [ + "ref" + ] } ], "additionalProperties": false diff --git a/src/main/resources/wiki/FAQ.md b/src/main/resources/wiki/FAQ.md index 5b4f0941..44720af3 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/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/main/resources/wiki/Specification-\342\200\220-Schema.md" "b/src/main/resources/wiki/Specification-\342\200\220-Schema.md" index 7fecdb93..50e044c5 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/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 125b9098..4cc593b4 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java @@ -40,7 +40,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 d204d32e..ecb1fd4e 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java @@ -39,7 +39,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 c6166dd6..05e82d7d 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java @@ -39,7 +39,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 38d5a57c..a4b8eaba 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java @@ -43,7 +43,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 f1ba6e64..8e342bb9 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java @@ -45,7 +45,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 29b8cdb4..c3b93f23 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java @@ -44,7 +44,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 new file mode 100644 index 00000000..258abb67 --- /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/aggregates/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/aggregates/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/aggregates/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/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() + .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/aggregates/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/aggregates/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/aggregates/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/aggregates/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/aggregates/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/aggregates/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/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 1ae003d4..516198c8 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java @@ -41,7 +41,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 cbacf53c..382112fd 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java @@ -46,7 +46,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 9cf490d9..318cda15 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java @@ -83,7 +83,7 @@ public void handleToolCallShouldMergeToolWithParameters() throws Exception { */ @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 31846f0f..e02f3513 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/rules/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/rules/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/rules/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/aggregates/aggregate-basic.yaml b/src/test/resources/aggregates/aggregate-basic.yaml new file mode 100644 index 00000000..5d263221 --- /dev/null +++ b/src/test/resources/aggregates/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/aggregates/aggregate-hints-override.yaml b/src/test/resources/aggregates/aggregate-hints-override.yaml new file mode 100644 index 00000000..1b8b2d77 --- /dev/null +++ b/src/test/resources/aggregates/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/aggregates/aggregate-invalid-ref.yaml b/src/test/resources/aggregates/aggregate-invalid-ref.yaml new file mode 100644 index 00000000..2cd9d9c2 --- /dev/null +++ b/src/test/resources/aggregates/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/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/rules/spectral-semantics-consistent.yaml b/src/test/resources/rules/spectral-semantics-consistent.yaml new file mode 100644 index 00000000..8aac13e3 --- /dev/null +++ b/src/test/resources/rules/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/rules/spectral-semantics-inconsistent.yaml b/src/test/resources/rules/spectral-semantics-inconsistent.yaml new file mode 100644 index 00000000..521371ac --- /dev/null +++ b/src/test/resources/rules/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 6c4fee5e12b0b0ee39beccf0b3c6ab001e524a5a Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:49:04 -0400 Subject: [PATCH 2/4] feat: support aggregate mock output parameters in deserializer Disambiguate ConsumedOutputParameter (name + JsonPath value starting with $) from aggregate mock functions (name + static/template value) so the deserializer routes each to the correct field (setMapping vs setValue). Add shared mock integration test proving MCP and REST adapters produce identical payloads from a single aggregate function mock definition. --- pom.xml | 6 +- .../naftiko/engine/AggregateRefResolver.java | 15 +++ .../naftiko/spec/AggregateFunctionSpec.java | 5 +- .../spec/OutputParameterDeserializer.java | 19 ++-- .../engine/AggregateRefResolverTest.java | 39 ++++++++ .../AggregateSharedMockIntegrationTest.java | 94 +++++++++++++++++++ .../OutputParameterDeserializationTest.java | 19 ++++ .../aggregates/aggregate-shared-mock.yaml | 61 ++++++++++++ 8 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java create mode 100644 src/test/resources/aggregates/aggregate-shared-mock.yaml diff --git a/pom.xml b/pom.xml index 03b50dba..6ba110b1 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,8 @@ 1.11.4 12.0.25 2.5.2 + 5.12.2 + 0.8.13 @@ -158,7 +160,7 @@ org.junit.jupiter junit-jupiter - 6.0.2 + ${junit.jupiter.version} test @@ -208,7 +210,7 @@ org.jacoco jacoco-maven-plugin - 0.8.11 + ${jacoco.version} prepare-agent diff --git a/src/main/java/io/naftiko/engine/AggregateRefResolver.java b/src/main/java/io/naftiko/engine/AggregateRefResolver.java index 85b02df8..0d8eb180 100644 --- a/src/main/java/io/naftiko/engine/AggregateRefResolver.java +++ b/src/main/java/io/naftiko/engine/AggregateRefResolver.java @@ -32,6 +32,7 @@ import io.naftiko.spec.exposes.RestServerResourceSpec; import io.naftiko.spec.exposes.RestServerSpec; import io.naftiko.spec.exposes.ServerSpec; +import io.naftiko.spec.exposes.StepOutputMappingSpec; /** * Resolves aggregate function references ({@code ref}) in adapter units (MCP tools, REST @@ -136,6 +137,13 @@ void resolveMcpToolRef(McpServerToolSpec tool, } } + // Merge step output mappings (function provides default, tool overrides) + if (tool.getMappings().isEmpty() && !function.getMappings().isEmpty()) { + for (StepOutputMappingSpec mapping : function.getMappings()) { + tool.getMappings().add(mapping); + } + } + // Merge inputParameters (function provides default, tool overrides) if (tool.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) { for (InputParameterSpec param : function.getInputParameters()) { @@ -191,6 +199,13 @@ void resolveRestOperationRef(RestServerOperationSpec op, } } + // Merge step output mappings + if (op.getMappings().isEmpty() && !function.getMappings().isEmpty()) { + for (StepOutputMappingSpec mapping : function.getMappings()) { + op.getMappings().add(mapping); + } + } + // Merge inputParameters if (op.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) { for (InputParameterSpec param : function.getInputParameters()) { diff --git a/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java index a5750103..74c388f5 100644 --- a/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java +++ b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java @@ -18,6 +18,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import com.fasterxml.jackson.annotation.JsonInclude; +import io.naftiko.spec.exposes.StepOutputMappingSpec; import io.naftiko.spec.exposes.OperationStepSpec; import io.naftiko.spec.exposes.ServerCallSpec; @@ -48,7 +49,7 @@ public class AggregateFunctionSpec { private final List steps; @JsonInclude(JsonInclude.Include.NON_EMPTY) - private final List> mappings; + private final List mappings; @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List outputParameters; @@ -108,7 +109,7 @@ public List getSteps() { return steps; } - public List> getMappings() { + public List getMappings() { return mappings; } diff --git a/src/main/java/io/naftiko/spec/OutputParameterDeserializer.java b/src/main/java/io/naftiko/spec/OutputParameterDeserializer.java index 42306773..a935f971 100644 --- a/src/main/java/io/naftiko/spec/OutputParameterDeserializer.java +++ b/src/main/java/io/naftiko/spec/OutputParameterDeserializer.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; /** * Custom deserializer for OutputParameterSpec that handles nested structure definitions including @@ -51,14 +50,20 @@ private OutputParameterSpec deserializeNode(JsonNode node, DeserializationContex if (node.has("mapping")) { spec.setMapping(node.get("mapping").asText()); - } else if (node.has("value") && node.has("name")) { - // ConsumedOutputParameter uses "value" for JsonPath extraction (has name + value) - spec.setMapping(node.get("value").asText()); } - if (node.has("value") && !node.has("name")) { - // MappedOutputParameter uses "value" for static runtime values (no name) - spec.setValue(node.get("value").asText()); + if (node.has("value")) { + String rawValue = node.get("value").asText(); + String trimmedValue = rawValue != null ? rawValue.trim() : ""; + + // ConsumedOutputParameter uses "value" for JsonPath extraction (name + value + // starting with $). Aggregate mock functions also use name + value, but with + // static/template strings — those must stay in setValue(). + if (node.has("name") && trimmedValue.startsWith("$") && !node.has("mapping")) { + spec.setMapping(rawValue); + } else { + spec.setValue(rawValue); + } } if (node.has("const")) { diff --git a/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java b/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java index 2eeaa9ec..dfcc0d42 100644 --- a/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java +++ b/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java @@ -29,6 +29,7 @@ import io.naftiko.spec.exposes.McpToolHintsSpec; import io.naftiko.spec.exposes.RestServerOperationSpec; import io.naftiko.spec.exposes.ServerCallSpec; +import io.naftiko.spec.exposes.StepOutputMappingSpec; /** * Unit tests for AggregateRefResolver — ref resolution, merge semantics, hints derivation. @@ -195,6 +196,25 @@ void resolveMcpToolRefShouldInheritInputParameters() { assertEquals("location", tool.getInputParameters().get(0).getName()); } + @Test + void resolveMcpToolRefShouldInheritMappings() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + fn.getMappings().add(new StepOutputMappingSpec("result", "$.lookup.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); + + assertEquals(1, tool.getMappings().size()); + assertEquals("result", tool.getMappings().get(0).getTargetName()); + assertEquals("$.lookup.data", tool.getMappings().get(0).getValue()); + } + @Test void resolveMcpToolRefShouldInheritDescription() { AggregateFunctionSpec fn = new AggregateFunctionSpec(); @@ -263,6 +283,25 @@ void resolveRestOperationRefShouldInheritCallFromFunction() { assertEquals("mock-api.get-data", op.getCall().getOperation()); } + @Test + void resolveRestOperationRefShouldInheritMappings() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + fn.getMappings().add(new StepOutputMappingSpec("result", "$.lookup.data")); + + Map map = Map.of("data.get-data", fn); + + RestServerOperationSpec op = new RestServerOperationSpec(); + op.setRef("data.get-data"); + + resolver.resolveRestOperationRef(op, map); + + assertEquals(1, op.getMappings().size()); + assertEquals("result", op.getMappings().get(0).getTargetName()); + assertEquals("$.lookup.data", op.getMappings().get(0).getValue()); + } + // ── deriveHints ── @Test diff --git a/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java new file mode 100644 index 00000000..209b154f --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java @@ -0,0 +1,94 @@ +/** + * 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.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.File; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.Method; +import org.restlet.data.Status; +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.engine.exposes.mcp.McpServerAdapter; +import io.naftiko.engine.exposes.mcp.ProtocolDispatcher; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.exposes.RestServerOperationSpec; +import io.naftiko.spec.exposes.RestServerSpec; + +/** + * Integration test proving that a single aggregate function mock can be reused unchanged by + * both MCP and REST adapters. + */ +public class AggregateSharedMockIntegrationTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void aggregateMockShouldReturnSamePayloadForMcpAndRest() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-shared-mock.yaml"); + + McpServerAdapter mcpAdapter = (McpServerAdapter) capability.getServerAdapters().get(0); + ProtocolDispatcher dispatcher = new ProtocolDispatcher(mcpAdapter); + + JsonNode mcpResponse = dispatcher.dispatch(JSON.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"hello\",\"arguments\":{\"name\":\"Nina\"}}}")); + + assertFalse(mcpResponse.path("result").path("isError").asBoolean(), + "MCP tools/call should not fail for aggregate mock ref"); + + JsonNode mcpPayload = JSON.readTree(mcpResponse.path("result").path("content") + .get(0).path("text").asText()); + + RestServerAdapter restAdapter = (RestServerAdapter) capability.getServerAdapters().get(1); + RestServerSpec restSpec = (RestServerSpec) restAdapter.getSpec(); + ResourceRestlet restlet = new ResourceRestlet(capability, restSpec, + restSpec.getResources().get(0)); + RestServerOperationSpec restOperation = restSpec.getResources().get(0).getOperations().get(0); + + assertEquals(2, restOperation.getOutputParameters().size(), + "REST operation should inherit two aggregate output parameters"); + + assertTrue(restlet.canBuildMockResponse(restOperation), + "REST operation should inherit aggregate mock output parameters"); + + Request request = new Request(Method.GET, "http://localhost/hello?name=Nina"); + Response response = new Response(request); + restlet.sendMockResponse(restOperation, response, Map.of("name", "Nina")); + + assertEquals(Status.SUCCESS_OK, response.getStatus()); + JsonNode restPayload = JSON.readTree(response.getEntity().getText()); + + assertEquals("Hello, Nina!", mcpPayload.path("message").asText()); + assertEquals("aggregate-mock", mcpPayload.path("source").asText()); + assertEquals(mcpPayload, restPayload, + "MCP and REST should share the same aggregate mock output"); + } + + private Capability loadCapability(String path) throws Exception { + File file = new File(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); + } +} diff --git a/src/test/java/io/naftiko/spec/OutputParameterDeserializationTest.java b/src/test/java/io/naftiko/spec/OutputParameterDeserializationTest.java index 93f39171..1fa479df 100644 --- a/src/test/java/io/naftiko/spec/OutputParameterDeserializationTest.java +++ b/src/test/java/io/naftiko/spec/OutputParameterDeserializationTest.java @@ -99,6 +99,25 @@ public void testConsumedOutputParameterUsesValueField() throws Exception { assertEquals("userid", spec.getName(), "Name should be parsed"); assertEquals("string", spec.getType(), "Type should be parsed"); assertEquals("$.id", spec.getMapping(), "Value alias should populate mapping"); + assertNull(spec.getValue(), "Consumed output JsonPath alias should not populate value"); + } + + @Test + public void testNamedMockOutputParameterShouldKeepStaticValue() throws Exception { + String yamlSnippet = """ + name: message + type: string + value: "Hello, {{name}}!" + """; + + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + OutputParameterSpec spec = mapper.readValue(yamlSnippet, OutputParameterSpec.class); + + assertEquals("message", spec.getName(), "Name should be parsed"); + assertEquals("string", spec.getType(), "Type should be parsed"); + assertEquals("Hello, {{name}}!", spec.getValue(), + "Named mock output should preserve static/template value"); + assertNull(spec.getMapping(), "Named mock output should not be re-routed to mapping"); } @Test diff --git a/src/test/resources/aggregates/aggregate-shared-mock.yaml b/src/test/resources/aggregates/aggregate-shared-mock.yaml new file mode 100644 index 00000000..bc1ae7b1 --- /dev/null +++ b/src/test/resources/aggregates/aggregate-shared-mock.yaml @@ -0,0 +1,61 @@ +# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "Aggregate Shared Mock Test" + description: "Shared aggregate mock function consumed by MCP and REST refs" + tags: + - Test + - Aggregate + created: "2026-04-09" + modified: "2026-04-09" + +capability: + aggregates: + - label: "Greeting" + namespace: "greeting" + functions: + - name: "hello" + description: "Builds a greeting payload from input parameters." + inputParameters: + - name: "name" + type: "string" + description: "Name to greet" + outputParameters: + - name: "message" + type: "string" + value: "Hello, {{name}}!" + - name: "source" + type: "string" + value: "aggregate-mock" + + exposes: + - type: "mcp" + address: "localhost" + port: 9200 + namespace: "greeting-mcp" + description: "MCP adapter using aggregate ref in mock mode." + tools: + - name: "hello" + description: "Return greeting payload." + ref: "greeting.hello" + + - type: "rest" + address: "localhost" + port: 9201 + namespace: "greeting-rest" + resources: + - path: "/hello" + name: "hello" + description: "Greeting resource" + operations: + - method: "GET" + name: "hello" + ref: "greeting.hello" + inputParameters: + - name: "name" + in: "query" + type: "string" + description: "Name to greet" + + consumes: [] From 5975c5e5be02d8967d923f7da504275001a5250d Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:27:39 -0400 Subject: [PATCH 3/4] feat: runtime aggregate function delegation and package reorganization - Add Aggregate, AggregateFunction, FunctionResult engine classes - Adapters delegate to AggregateFunction at runtime instead of copying fields - AggregateRefResolver now only validates refs and derives MCP hints - Move utility classes to engine/util (Resolver, Converter, etc.) - Move aggregate classes to engine/aggregates - Move ConsumesImportResolver to engine/consumes --- src/main/java/io/naftiko/Capability.java | 55 ++++++- .../naftiko/engine/aggregates/Aggregate.java | 64 ++++++++ .../engine/aggregates/AggregateFunction.java | 152 ++++++++++++++++++ .../AggregateRefResolver.java | 126 ++++----------- .../engine/aggregates/FunctionResult.java | 52 ++++++ .../ConsumesImportResolver.java | 2 +- .../consumes/http/HttpClientAdapter.java | 2 +- .../engine/exposes/OperationStepExecutor.java | 6 +- .../engine/exposes/mcp/McpServerAdapter.java | 12 +- .../engine/exposes/mcp/ToolHandler.java | 46 +++++- .../engine/exposes/rest/ResourceRestlet.java | 66 +++++++- .../exposes/rest/RestServerAdapter.java | 2 +- .../rest/ServerAuthenticationRestlet.java | 2 +- .../engine/{ => util}/BindingResolver.java | 2 +- .../naftiko/engine/{ => util}/Converter.java | 2 +- .../engine/{ => util}/LookupExecutor.java | 5 +- .../naftiko/engine/{ => util}/Resolver.java | 2 +- .../{ => util}/StepExecutionContext.java | 2 +- .../AggregateRefResolverTest.java | 82 ++-------- .../http/ConsumesImportResolverTest.java | 2 +- .../consumes/http/ForwardValueFieldTest.java | 2 +- .../engine/consumes/http/HeadersTest.java | 2 +- .../consumes/http/HtmlIntegrationTest.java | 2 +- .../http/MarkdownIntegrationTest.java | 2 +- .../exposes/OutputMappingExtensionTest.java | 2 +- .../exposes/mcp/AggregateIntegrationTest.java | 22 ++- .../AggregateSharedMockIntegrationTest.java | 24 +-- .../rest/HeaderQueryIntegrationTest.java | 2 +- .../exposes/rest/OperationsRestletTest.java | 2 +- .../exposes/rest/ResourceRestletTest.java | 2 +- .../{ => util}/BindingResolverTest.java | 2 +- .../engine/{ => util}/ConverterTest.java | 2 +- .../engine/{ => util}/LookupExecutorTest.java | 2 +- .../engine/{ => util}/ResolverTest.java | 2 +- ...tep10ShipyardMcpClientIntegrationTest.java | 2 +- ...Step5ShipyardMcpClientIntegrationTest.java | 5 +- ...Step6ShipyardMcpClientIntegrationTest.java | 3 +- ...Step7ShipyardMcpClientIntegrationTest.java | 3 +- ...Step8ShipyardMcpClientIntegrationTest.java | 2 +- ...Step9ShipyardMcpClientIntegrationTest.java | 2 +- 40 files changed, 542 insertions(+), 229 deletions(-) create mode 100644 src/main/java/io/naftiko/engine/aggregates/Aggregate.java create mode 100644 src/main/java/io/naftiko/engine/aggregates/AggregateFunction.java rename src/main/java/io/naftiko/engine/{ => aggregates}/AggregateRefResolver.java (63%) create mode 100644 src/main/java/io/naftiko/engine/aggregates/FunctionResult.java rename src/main/java/io/naftiko/engine/{ => consumes}/ConsumesImportResolver.java (99%) rename src/main/java/io/naftiko/engine/{ => util}/BindingResolver.java (99%) rename src/main/java/io/naftiko/engine/{ => util}/Converter.java (99%) rename src/main/java/io/naftiko/engine/{ => util}/LookupExecutor.java (97%) rename src/main/java/io/naftiko/engine/{ => util}/Resolver.java (99%) rename src/main/java/io/naftiko/engine/{ => util}/StepExecutionContext.java (98%) rename src/test/java/io/naftiko/engine/{ => aggregates}/AggregateRefResolverTest.java (82%) rename src/test/java/io/naftiko/engine/{ => util}/BindingResolverTest.java (99%) rename src/test/java/io/naftiko/engine/{ => util}/ConverterTest.java (99%) rename src/test/java/io/naftiko/engine/{ => util}/LookupExecutorTest.java (99%) rename src/test/java/io/naftiko/engine/{ => util}/ResolverTest.java (99%) diff --git a/src/main/java/io/naftiko/Capability.java b/src/main/java/io/naftiko/Capability.java index 3b9fd81b..2388f0e7 100644 --- a/src/main/java/io/naftiko/Capability.java +++ b/src/main/java/io/naftiko/Capability.java @@ -21,16 +21,20 @@ 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.engine.aggregates.Aggregate; +import io.naftiko.engine.aggregates.AggregateFunction; +import io.naftiko.engine.aggregates.AggregateRefResolver; +import io.naftiko.engine.exposes.OperationStepExecutor; +import io.naftiko.spec.AggregateSpec; import io.naftiko.spec.ExecutionContext; import io.naftiko.engine.consumes.ClientAdapter; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.engine.consumes.http.HttpClientAdapter; import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.engine.exposes.mcp.McpServerAdapter; import io.naftiko.engine.exposes.rest.RestServerAdapter; import io.naftiko.engine.exposes.skill.SkillServerAdapter; +import io.naftiko.engine.util.BindingResolver; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.consumes.ClientSpec; import io.naftiko.spec.consumes.HttpClientSpec; @@ -47,6 +51,7 @@ public class Capability { private volatile NaftikoSpec spec; private volatile List serverAdapters; private volatile List clientAdapters; + private volatile List aggregates; private volatile Map bindings; public Capability(NaftikoSpec spec) throws Exception { @@ -69,10 +74,19 @@ public Capability(NaftikoSpec spec, String capabilityDir) throws Exception { importResolver.resolveImports(spec.getCapability().getConsumes(), capabilityDir); } - // Resolve aggregate function refs before adapter initialization + // Resolve aggregate function refs (validate + derive MCP hints) before adapter init AggregateRefResolver aggregateRefResolver = new AggregateRefResolver(); aggregateRefResolver.resolve(spec); + // Build runtime aggregates (must happen after imports are resolved) + this.aggregates = new CopyOnWriteArrayList<>(); + if (spec.getCapability() != null && !spec.getCapability().getAggregates().isEmpty()) { + OperationStepExecutor sharedExecutor = new OperationStepExecutor(this); + for (AggregateSpec aggSpec : spec.getCapability().getAggregates()) { + this.aggregates.add(new Aggregate(aggSpec, sharedExecutor)); + } + } + // Resolve bindings early for injection into adapters BindingResolver bindingResolver = new BindingResolver(); ExecutionContext context = new ExecutionContext() { @@ -128,6 +142,39 @@ public List getServerAdapters() { return serverAdapters; } + public List getAggregates() { + return aggregates; + } + + /** + * Look up an aggregate function by ref key ({@code "namespace.functionName"}). + * + * @param ref the ref key, e.g. {@code "forecast.get-forecast"} + * @return the matching {@link AggregateFunction} + * @throws IllegalArgumentException if the ref cannot be resolved + */ + public AggregateFunction lookupFunction(String ref) { + int dot = ref.indexOf('.'); + if (dot <= 0 || dot == ref.length() - 1) { + throw new IllegalArgumentException( + "Invalid aggregate function ref format: '" + ref + + "'. Expected 'namespace.functionName'"); + } + String namespace = ref.substring(0, dot); + String functionName = ref.substring(dot + 1); + + for (Aggregate agg : aggregates) { + if (agg.getNamespace().equals(namespace)) { + AggregateFunction fn = agg.findFunction(functionName); + if (fn != null) { + return fn; + } + } + } + throw new IllegalArgumentException( + "Unknown aggregate function ref: '" + ref + "'"); + } + /** * Returns the map of resolved bindings. These are injected into parameter resolution contexts. * diff --git a/src/main/java/io/naftiko/engine/aggregates/Aggregate.java b/src/main/java/io/naftiko/engine/aggregates/Aggregate.java new file mode 100644 index 00000000..5c60b3c7 --- /dev/null +++ b/src/main/java/io/naftiko/engine/aggregates/Aggregate.java @@ -0,0 +1,64 @@ +/** + * 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.aggregates; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import io.naftiko.engine.exposes.OperationStepExecutor; +import io.naftiko.spec.AggregateFunctionSpec; +import io.naftiko.spec.AggregateSpec; + +/** + * Runtime representation of a domain aggregate. + * + *

Wraps an {@link AggregateSpec} (YAML data) and owns executable + * {@link AggregateFunction} instances for each function defined in the spec.

+ */ +public class Aggregate { + + private final String namespace; + private final List functions; + + public Aggregate(AggregateSpec spec, OperationStepExecutor stepExecutor) { + this.namespace = spec.getNamespace(); + this.functions = new CopyOnWriteArrayList<>(); + for (AggregateFunctionSpec fnSpec : spec.getFunctions()) { + this.functions.add(new AggregateFunction(fnSpec, stepExecutor)); + } + } + + public String getNamespace() { + return namespace; + } + + public List getFunctions() { + return functions; + } + + /** + * Find a function by name within this aggregate. + * + * @param name the function name + * @return the function, or {@code null} if not found + */ + public AggregateFunction findFunction(String name) { + for (AggregateFunction fn : functions) { + if (fn.getName().equals(name)) { + return fn; + } + } + return null; + } + +} diff --git a/src/main/java/io/naftiko/engine/aggregates/AggregateFunction.java b/src/main/java/io/naftiko/engine/aggregates/AggregateFunction.java new file mode 100644 index 00000000..1cbfbe5d --- /dev/null +++ b/src/main/java/io/naftiko/engine/aggregates/AggregateFunction.java @@ -0,0 +1,152 @@ +/** + * 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.aggregates; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.naftiko.engine.exposes.OperationStepExecutor; +import io.naftiko.engine.util.Resolver; +import io.naftiko.spec.AggregateFunctionSpec; +import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.OutputParameterSpec; +import io.naftiko.spec.SemanticsSpec; +import io.naftiko.spec.exposes.OperationStepSpec; +import io.naftiko.spec.exposes.ServerCallSpec; +import io.naftiko.spec.exposes.StepOutputMappingSpec; + +/** + * Runtime-executable wrapper around an {@link AggregateFunctionSpec}. + * + *

Holds the spec data (from YAML) plus the {@link OperationStepExecutor} needed to actually + * run the function. Adapters (MCP tools, REST operations) that reference an aggregate function + * delegate execution here instead of duplicating the function's fields.

+ */ +public class AggregateFunction { + + private final AggregateFunctionSpec spec; + private final OperationStepExecutor stepExecutor; + + AggregateFunction(AggregateFunctionSpec spec, OperationStepExecutor stepExecutor) { + this.spec = spec; + this.stepExecutor = stepExecutor; + } + + public String getName() { + return spec.getName(); + } + + public String getDescription() { + return spec.getDescription(); + } + + public SemanticsSpec getSemantics() { + return spec.getSemantics(); + } + + public List getInputParameters() { + return spec.getInputParameters(); + } + + public List getOutputParameters() { + return spec.getOutputParameters(); + } + + public ServerCallSpec getCall() { + return spec.getCall(); + } + + public Map getWith() { + return spec.getWith(); + } + + public List getSteps() { + return spec.getSteps(); + } + + public List getMappings() { + return spec.getMappings(); + } + + /** + * Execute this aggregate function with the given parameters. + * + *

Supports three modes: + *

    + *
  1. Mock — no call, no steps: builds output from {@code outputParameters} values
  2. + *
  3. Orchestrated — steps defined: runs step sequence + optional mappings
  4. + *
  5. Simple call — call defined: single HTTP dispatch
  6. + *
+ * + * @param parameters resolved input parameters (merged with adapter-level 'with') + * @return a transport-neutral {@link FunctionResult} + */ + public FunctionResult execute(Map parameters) throws Exception { + Map merged = new HashMap<>(); + if (parameters != null) { + merged.putAll(parameters); + } + + // Merge function-level 'with' parameters + OperationStepExecutor.mergeWithParameters(spec.getWith(), merged, null); + + boolean hasCall = spec.getCall() != null; + boolean isOrchestrated = spec.getSteps() != null && !spec.getSteps().isEmpty(); + + // Mock mode + if (!hasCall && !isOrchestrated) { + ObjectMapper mapper = new ObjectMapper(); + JsonNode mockRoot = Resolver.buildMockData(spec.getOutputParameters(), mapper, merged); + return new FunctionResult(null, null, mockRoot); + } + + // Orchestrated mode + if (isOrchestrated) { + OperationStepExecutor.StepExecutionResult stepResult = + stepExecutor.executeSteps(spec.getSteps(), merged); + + if (spec.getMappings() != null && !spec.getMappings().isEmpty()) { + String mapped = stepExecutor.resolveStepMappings( + spec.getMappings(), stepResult.stepContext); + if (mapped != null) { + return new FunctionResult(stepResult.lastContext, mapped, null); + } + } + + return new FunctionResult(stepResult.lastContext, null, null); + } + + // Simple call mode + OperationStepExecutor.HandlingContext found = + stepExecutor.execute(spec.getCall(), spec.getSteps(), merged, + "Function '" + spec.getName() + "'"); + + // Apply output parameter mappings if defined on the function + if (spec.getOutputParameters() != null && !spec.getOutputParameters().isEmpty() + && found != null && found.clientResponse != null + && found.clientResponse.getEntity() != null) { + String responseText = found.clientResponse.getEntity().getText(); + String mapped = stepExecutor.applyOutputMappings(responseText, + spec.getOutputParameters()); + if (mapped != null) { + return new FunctionResult(found, mapped, null); + } + } + + return new FunctionResult(found, null, null); + } + +} diff --git a/src/main/java/io/naftiko/engine/AggregateRefResolver.java b/src/main/java/io/naftiko/engine/aggregates/AggregateRefResolver.java similarity index 63% rename from src/main/java/io/naftiko/engine/AggregateRefResolver.java rename to src/main/java/io/naftiko/engine/aggregates/AggregateRefResolver.java index 0d8eb180..f91d5bc6 100644 --- a/src/main/java/io/naftiko/engine/AggregateRefResolver.java +++ b/src/main/java/io/naftiko/engine/aggregates/AggregateRefResolver.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.aggregates; import java.util.HashMap; import java.util.Map; @@ -20,37 +20,39 @@ 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; -import io.naftiko.spec.exposes.StepOutputMappingSpec; /** - * Resolves aggregate function references ({@code ref}) in adapter units (MCP tools, REST - * operations). Runs at capability load time, before server startup. - * + * Validates aggregate function references ({@code ref}) in adapter units (MCP tools, REST + * operations) and derives MCP-specific metadata. Runs at capability load time, before server + * startup. + * + *

+ * Validation ensures all refs point to known aggregate functions. For MCP tools, semantics are + * automatically derived into hints (with field-level override). Adapter-specific metadata + * (name, description) is inherited when not explicitly set on the adapter unit. + * *

- * 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). + * Execution fields ({@code call}, {@code steps}, {@code with}, {@code inputParameters}, + * {@code outputParameters}, {@code mappings}) are not copied — at runtime, adapters + * delegate to {@link AggregateFunction} instances held by the {@link io.naftiko.Capability}. */ public class AggregateRefResolver { /** - * Resolve all {@code ref} fields across adapter units in the given spec. Modifies specs - * in-place. + * Validate all {@code ref} fields across adapter units in the given spec and derive + * adapter-specific metadata. * * @param spec The root Naftiko spec to resolve - * @throws IllegalArgumentException if a ref target is unknown or a chained ref is detected + * @throws IllegalArgumentException if a ref target is unknown */ public void resolve(NaftikoSpec spec) { CapabilitySpec capability = spec.getCapability(); @@ -61,7 +63,7 @@ public void resolve(NaftikoSpec spec) { // Build lookup map: "namespace.functionName" → AggregateFunctionSpec Map functionMap = buildFunctionMap(capability); - // Resolve refs in all adapter units + // Validate refs and derive metadata in all adapter units for (ServerSpec serverSpec : capability.getExposes()) { if (serverSpec instanceof McpServerSpec mcpSpec) { for (McpServerToolSpec tool : mcpSpec.getTools()) { @@ -104,60 +106,24 @@ Map buildFunctionMap(CapabilitySpec capability) { } /** - * Resolve a ref on an MCP tool. Merges inherited fields and derives hints from semantics. + * Validate a ref on an MCP tool, inherit adapter-specific metadata, and derive MCP hints + * from semantics. Execution fields are not copied — they are resolved at runtime via + * {@link AggregateFunction}. */ void resolveMcpToolRef(McpServerToolSpec tool, Map functionMap) { AggregateFunctionSpec function = lookupFunction(tool.getRef(), functionMap); - // Merge name (function provides default, tool overrides) + // Inherit name (adapter-specific metadata) if (tool.getName() == null || tool.getName().isEmpty()) { tool.setName(function.getName()); } - // Merge description (function provides default, tool overrides) + // Inherit description (adapter-specific metadata) 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 step output mappings (function provides default, tool overrides) - if (tool.getMappings().isEmpty() && !function.getMappings().isEmpty()) { - for (StepOutputMappingSpec mapping : function.getMappings()) { - tool.getMappings().add(mapping); - } - } - - // 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()); @@ -166,63 +132,27 @@ void resolveMcpToolRef(McpServerToolSpec tool, } /** - * Resolve a ref on a REST operation. Merges inherited fields. + * Validate a ref on a REST operation and inherit adapter-specific metadata. + * Execution fields are not copied — they are resolved at runtime via + * {@link AggregateFunction}. */ void resolveRestOperationRef(RestServerOperationSpec op, Map functionMap) { AggregateFunctionSpec function = lookupFunction(op.getRef(), functionMap); - // Merge name + // Inherit name if (op.getName() == null || op.getName().isEmpty()) { op.setName(function.getName()); } - // Merge description + // Inherit 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 step output mappings - if (op.getMappings().isEmpty() && !function.getMappings().isEmpty()) { - for (StepOutputMappingSpec mapping : function.getMappings()) { - op.getMappings().add(mapping); - } - } - - // 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. + * Look up a function by ref key. Fails fast on unknown refs. */ AggregateFunctionSpec lookupFunction(String ref, Map functionMap) { diff --git a/src/main/java/io/naftiko/engine/aggregates/FunctionResult.java b/src/main/java/io/naftiko/engine/aggregates/FunctionResult.java new file mode 100644 index 00000000..c1e7e997 --- /dev/null +++ b/src/main/java/io/naftiko/engine/aggregates/FunctionResult.java @@ -0,0 +1,52 @@ +/** + * 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.aggregates; + +import com.fasterxml.jackson.databind.JsonNode; +import io.naftiko.engine.exposes.OperationStepExecutor; + +/** + * Transport-neutral result of executing an aggregate function. + * + *

Adapters (MCP, REST) convert this into their protocol-specific response format.

+ */ +public class FunctionResult { + + /** The last HTTP handling context (simple call or last orchestrated step). May be null in mock mode. */ + public final OperationStepExecutor.HandlingContext lastContext; + + /** Resolved step mappings output (orchestrated mode with mappings). May be null. */ + public final String mappedOutput; + + /** Mock output built from outputParameter value fields. May be null. */ + public final JsonNode mockOutput; + + FunctionResult(OperationStepExecutor.HandlingContext lastContext, String mappedOutput, + JsonNode mockOutput) { + this.lastContext = lastContext; + this.mappedOutput = mappedOutput; + this.mockOutput = mockOutput; + } + + /** True when the result was produced by mock mode (no call, no steps). */ + public boolean isMock() { + return mockOutput != null; + } + + /** True when the result includes mapped step output. */ + public boolean hasMappedOutput() { + return mappedOutput != null; + } + +} diff --git a/src/main/java/io/naftiko/engine/ConsumesImportResolver.java b/src/main/java/io/naftiko/engine/consumes/ConsumesImportResolver.java similarity index 99% rename from src/main/java/io/naftiko/engine/ConsumesImportResolver.java rename to src/main/java/io/naftiko/engine/consumes/ConsumesImportResolver.java index ebd81a2f..94799880 100644 --- a/src/main/java/io/naftiko/engine/ConsumesImportResolver.java +++ b/src/main/java/io/naftiko/engine/consumes/ConsumesImportResolver.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.consumes; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/io/naftiko/engine/consumes/http/HttpClientAdapter.java b/src/main/java/io/naftiko/engine/consumes/http/HttpClientAdapter.java index af6eca39..b788f9f0 100644 --- a/src/main/java/io/naftiko/engine/consumes/http/HttpClientAdapter.java +++ b/src/main/java/io/naftiko/engine/consumes/http/HttpClientAdapter.java @@ -18,8 +18,8 @@ import org.restlet.data.ChallengeResponse; import org.restlet.data.ChallengeScheme; import io.naftiko.Capability; -import io.naftiko.engine.Resolver; import io.naftiko.engine.consumes.ClientAdapter; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.InputParameterSpec; import io.naftiko.spec.consumes.ApiKeyAuthenticationSpec; import io.naftiko.spec.consumes.AuthenticationSpec; diff --git a/src/main/java/io/naftiko/engine/exposes/OperationStepExecutor.java b/src/main/java/io/naftiko/engine/exposes/OperationStepExecutor.java index 9cb5fa0e..eba97a36 100644 --- a/src/main/java/io/naftiko/engine/exposes/OperationStepExecutor.java +++ b/src/main/java/io/naftiko/engine/exposes/OperationStepExecutor.java @@ -30,11 +30,11 @@ import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.naftiko.Capability; -import io.naftiko.engine.LookupExecutor; -import io.naftiko.engine.Resolver; -import io.naftiko.engine.StepExecutionContext; import io.naftiko.engine.consumes.ClientAdapter; import io.naftiko.engine.consumes.http.HttpClientAdapter; +import io.naftiko.engine.util.LookupExecutor; +import io.naftiko.engine.util.Resolver; +import io.naftiko.engine.util.StepExecutionContext; import io.naftiko.spec.InputParameterSpec; import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.consumes.HttpClientOperationSpec; 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 f19ce23d..e7165c0c 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java @@ -24,6 +24,7 @@ import org.restlet.Context; import io.modelcontextprotocol.spec.McpSchema; import io.naftiko.Capability; +import io.naftiko.engine.aggregates.AggregateFunction; import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.spec.InputParameterSpec; import io.naftiko.spec.exposes.McpServerSpec; @@ -126,8 +127,15 @@ private McpSchema.Tool buildMcpTool(McpServerToolSpec toolSpec) { Map schemaProperties = new LinkedHashMap<>(); List required = new ArrayList<>(); - if (toolSpec.getInputParameters() != null) { - for (InputParameterSpec param : toolSpec.getInputParameters()) { + // Resolve inputParameters: prefer tool-level, fall back to aggregate function + List inputParams = toolSpec.getInputParameters(); + if ((inputParams == null || inputParams.isEmpty()) && toolSpec.getRef() != null) { + AggregateFunction fn = getCapability().lookupFunction(toolSpec.getRef()); + inputParams = fn.getInputParameters(); + } + + if (inputParams != null) { + for (InputParameterSpec param : inputParams) { Map property = new HashMap<>(); property.put("type", param.getType() != null ? param.getType() : "string"); diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java b/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java index 9936b375..470db74c 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java @@ -21,8 +21,10 @@ import java.util.logging.Logger; import io.modelcontextprotocol.spec.McpSchema; import io.naftiko.Capability; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.aggregates.AggregateFunction; +import io.naftiko.engine.aggregates.FunctionResult; import io.naftiko.engine.exposes.OperationStepExecutor; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.exposes.McpServerToolSpec; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -96,6 +98,11 @@ public McpSchema.CallToolResult handleToolCall(String toolName, Map parameters) throws Exception { + try { + AggregateFunction fn = capability.lookupFunction(toolSpec.getRef()); + FunctionResult result = fn.execute(parameters); + + if (result.isMock()) { + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString( + result.mockOutput != null ? result.mockOutput + : mapper.createObjectNode()); + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(json)), false, null, null); + } + + if (result.hasMappedOutput()) { + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(result.mappedOutput)), false, null, + null); + } + + return buildToolResult(toolSpec, result.lastContext); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + logger.warning("Error during aggregate function call for tool '" + toolName + "': " + + e); + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent( + "Error during aggregate function call: " + e.getMessage())), + true, null, null); + } + } + /** * Build an MCP CallToolResult from output parameter {@code value} fields (mock mode). * Mustache templates in values are resolved against the given parameters. diff --git a/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java b/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java index 23ee5b51..36e65c3e 100644 --- a/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java @@ -20,11 +20,13 @@ import org.restlet.data.MediaType; import org.restlet.data.Status; import io.naftiko.Capability; -import io.naftiko.engine.Converter; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.aggregates.AggregateFunction; +import io.naftiko.engine.aggregates.FunctionResult; import io.naftiko.engine.consumes.ClientAdapter; import io.naftiko.engine.consumes.http.HttpClientAdapter; import io.naftiko.engine.exposes.OperationStepExecutor; +import io.naftiko.engine.util.Converter; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.exposes.RestServerForwardSpec; import io.naftiko.spec.exposes.RestServerOperationSpec; @@ -93,6 +95,11 @@ private boolean handleFromOperationSpec(Request request, Response response) { OperationStepExecutor.mergeWithParameters(serverOp.getWith(), inputParameters, getServerSpec().getNamespace()); + // Delegate to aggregate function when ref is set + if (serverOp.getRef() != null) { + return executeViaAggregate(serverOp, request, response, inputParameters); + } + if (serverOp.getCall() != null) { try { found = stepExecutor.findClientRequestFor(serverOp.getCall(), @@ -190,6 +197,61 @@ private boolean handleFromOperationSpec(Request request, Response response) { return false; } + /** + * Execute an operation by delegating to its referenced aggregate function. + */ + private boolean executeViaAggregate(RestServerOperationSpec serverOp, Request request, + Response response, Map inputParameters) { + try { + AggregateFunction fn = capability.lookupFunction(serverOp.getRef()); + FunctionResult result = fn.execute(inputParameters); + + if (result.isMock()) { + ObjectMapper mapper = new ObjectMapper(); + if (result.mockOutput != null) { + response.setStatus(Status.SUCCESS_OK); + response.setEntity( + mapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(result.mockOutput), + MediaType.APPLICATION_JSON); + } else { + response.setStatus(Status.SUCCESS_NO_CONTENT); + } + response.commit(); + return true; + } + + if (result.hasMappedOutput()) { + response.setStatus(Status.SUCCESS_OK); + response.setEntity(result.mappedOutput, MediaType.APPLICATION_JSON); + response.commit(); + return true; + } + + if (result.lastContext != null) { + response.setStatus(result.lastContext.clientResponse.getStatus()); + sendResponse(serverOp, response, result.lastContext); + return true; + } + + response.setStatus(Status.SERVER_ERROR_INTERNAL); + response.setEntity("No result from aggregate function: " + serverOp.getRef(), + MediaType.TEXT_PLAIN); + return true; + } catch (IllegalArgumentException e) { + Context.getCurrentLogger().warning("Error in aggregate function call: " + e); + response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + response.setEntity(e.getMessage(), MediaType.TEXT_PLAIN); + return true; + } catch (Exception e) { + Context.getCurrentLogger().warning("Error in aggregate function call: " + e); + response.setStatus(Status.SERVER_ERROR_INTERNAL); + response.setEntity("Error in aggregate function call\n\n" + e.toString(), + MediaType.TEXT_PLAIN); + return true; + } + } + /** * Check if an operation can build a mock response using static values from outputParameters. * Returns true if the operation has at least one outputParameter with a value. diff --git a/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java index 32b582ea..3c081bb7 100644 --- a/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/rest/RestServerAdapter.java @@ -30,8 +30,8 @@ import org.restlet.security.SecretVerifier; import org.restlet.security.Verifier; import io.naftiko.Capability; -import io.naftiko.engine.Resolver; import io.naftiko.engine.exposes.ServerAdapter; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.consumes.AuthenticationSpec; import io.naftiko.spec.consumes.BasicAuthenticationSpec; import io.naftiko.spec.consumes.DigestAuthenticationSpec; diff --git a/src/main/java/io/naftiko/engine/exposes/rest/ServerAuthenticationRestlet.java b/src/main/java/io/naftiko/engine/exposes/rest/ServerAuthenticationRestlet.java index de626884..64065998 100644 --- a/src/main/java/io/naftiko/engine/exposes/rest/ServerAuthenticationRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/rest/ServerAuthenticationRestlet.java @@ -25,7 +25,7 @@ import org.restlet.Restlet; import org.restlet.data.MediaType; import org.restlet.data.Status; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.consumes.ApiKeyAuthenticationSpec; import io.naftiko.spec.consumes.AuthenticationSpec; import io.naftiko.spec.consumes.BearerAuthenticationSpec; diff --git a/src/main/java/io/naftiko/engine/BindingResolver.java b/src/main/java/io/naftiko/engine/util/BindingResolver.java similarity index 99% rename from src/main/java/io/naftiko/engine/BindingResolver.java rename to src/main/java/io/naftiko/engine/util/BindingResolver.java index 1a4dd202..ce412016 100644 --- a/src/main/java/io/naftiko/engine/BindingResolver.java +++ b/src/main/java/io/naftiko/engine/util/BindingResolver.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import java.io.IOException; import java.lang.Iterable; diff --git a/src/main/java/io/naftiko/engine/Converter.java b/src/main/java/io/naftiko/engine/util/Converter.java similarity index 99% rename from src/main/java/io/naftiko/engine/Converter.java rename to src/main/java/io/naftiko/engine/util/Converter.java index b4b21e60..b6db764f 100644 --- a/src/main/java/io/naftiko/engine/Converter.java +++ b/src/main/java/io/naftiko/engine/util/Converter.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/io/naftiko/engine/LookupExecutor.java b/src/main/java/io/naftiko/engine/util/LookupExecutor.java similarity index 97% rename from src/main/java/io/naftiko/engine/LookupExecutor.java rename to src/main/java/io/naftiko/engine/util/LookupExecutor.java index 06c47f58..6d8211d6 100644 --- a/src/main/java/io/naftiko/engine/LookupExecutor.java +++ b/src/main/java/io/naftiko/engine/util/LookupExecutor.java @@ -11,10 +11,11 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import java.util.Iterator; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -130,7 +131,7 @@ private static ObjectNode extractFields(JsonNode entry, List fieldNames) * @param result The lookup result (an ObjectNode with extracted fields) * @param targetMap The map to merge the result into (output of the lookup step) */ - public static void mergeLookupResult(JsonNode result, java.util.Map targetMap) { + public static void mergeLookupResult(JsonNode result, Map targetMap) { if (result == null || !result.isObject() || targetMap == null) { return; } diff --git a/src/main/java/io/naftiko/engine/Resolver.java b/src/main/java/io/naftiko/engine/util/Resolver.java similarity index 99% rename from src/main/java/io/naftiko/engine/Resolver.java rename to src/main/java/io/naftiko/engine/util/Resolver.java index e459292c..8b2e5964 100644 --- a/src/main/java/io/naftiko/engine/Resolver.java +++ b/src/main/java/io/naftiko/engine/util/Resolver.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import java.util.HashMap; import java.util.List; diff --git a/src/main/java/io/naftiko/engine/StepExecutionContext.java b/src/main/java/io/naftiko/engine/util/StepExecutionContext.java similarity index 98% rename from src/main/java/io/naftiko/engine/StepExecutionContext.java rename to src/main/java/io/naftiko/engine/util/StepExecutionContext.java index fb811e1f..1b646ec0 100644 --- a/src/main/java/io/naftiko/engine/StepExecutionContext.java +++ b/src/main/java/io/naftiko/engine/util/StepExecutionContext.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import java.util.HashMap; import java.util.Map; diff --git a/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java b/src/test/java/io/naftiko/engine/aggregates/AggregateRefResolverTest.java similarity index 82% rename from src/test/java/io/naftiko/engine/AggregateRefResolverTest.java rename to src/test/java/io/naftiko/engine/aggregates/AggregateRefResolverTest.java index dfcc0d42..4face9e9 100644 --- a/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java +++ b/src/test/java/io/naftiko/engine/aggregates/AggregateRefResolverTest.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.aggregates; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeEach; @@ -21,7 +21,6 @@ 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; @@ -29,7 +28,6 @@ import io.naftiko.spec.exposes.McpToolHintsSpec; import io.naftiko.spec.exposes.RestServerOperationSpec; import io.naftiko.spec.exposes.ServerCallSpec; -import io.naftiko.spec.exposes.StepOutputMappingSpec; /** * Unit tests for AggregateRefResolver — ref resolution, merge semantics, hints derivation. @@ -105,7 +103,7 @@ void lookupFunctionShouldFailOnUnknownRef() { assertTrue(ex.getMessage().contains("unknown.nonexistent")); } - // ── MCP tool ref merge ── + // ── MCP tool ref metadata inheritance ── @Test void resolveMcpToolRefShouldInheritNameFromFunction() { @@ -140,7 +138,7 @@ void resolveMcpToolRefShouldNotOverrideExplicitName() { } @Test - void resolveMcpToolRefShouldInheritCallFromFunction() { + void resolveMcpToolRefShouldNotCopyCallFromFunction() { AggregateFunctionSpec fn = new AggregateFunctionSpec(); fn.setName("get-data"); fn.setDescription("Get data"); @@ -153,34 +151,15 @@ void resolveMcpToolRefShouldInheritCallFromFunction() { resolver.resolveMcpToolRef(tool, map); - assertNotNull(tool.getCall()); - assertEquals("mock-api.get-data", tool.getCall().getOperation()); + assertNull(tool.getCall(), "call should not be copied — resolved at runtime via AggregateFunction"); } @Test - void resolveMcpToolRefShouldNotOverrideExplicitCall() { + void resolveMcpToolRefShouldNotCopyInputParametersFromFunction() { 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(); + io.naftiko.spec.InputParameterSpec param = new io.naftiko.spec.InputParameterSpec(); param.setName("location"); param.setType("string"); fn.getInputParameters().add(param); @@ -192,27 +171,8 @@ void resolveMcpToolRefShouldInheritInputParameters() { resolver.resolveMcpToolRef(tool, map); - assertEquals(1, tool.getInputParameters().size()); - assertEquals("location", tool.getInputParameters().get(0).getName()); - } - - @Test - void resolveMcpToolRefShouldInheritMappings() { - AggregateFunctionSpec fn = new AggregateFunctionSpec(); - fn.setName("get-data"); - fn.setDescription("Get data"); - fn.getMappings().add(new StepOutputMappingSpec("result", "$.lookup.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); - - assertEquals(1, tool.getMappings().size()); - assertEquals("result", tool.getMappings().get(0).getTargetName()); - assertEquals("$.lookup.data", tool.getMappings().get(0).getValue()); + assertTrue(tool.getInputParameters().isEmpty(), + "inputParameters should not be copied — resolved at runtime via AggregateFunction"); } @Test @@ -247,7 +207,7 @@ void resolveMcpToolRefShouldNotOverrideExplicitDescription() { assertEquals("Tool description", tool.getDescription()); } - // ── REST operation ref merge ── + // ── REST operation ref metadata inheritance ── @Test void resolveRestOperationRefShouldInheritNameFromFunction() { @@ -266,7 +226,7 @@ void resolveRestOperationRefShouldInheritNameFromFunction() { } @Test - void resolveRestOperationRefShouldInheritCallFromFunction() { + void resolveRestOperationRefShouldNotCopyCallFromFunction() { AggregateFunctionSpec fn = new AggregateFunctionSpec(); fn.setName("get-data"); fn.setDescription("Get data"); @@ -279,27 +239,7 @@ void resolveRestOperationRefShouldInheritCallFromFunction() { resolver.resolveRestOperationRef(op, map); - assertNotNull(op.getCall()); - assertEquals("mock-api.get-data", op.getCall().getOperation()); - } - - @Test - void resolveRestOperationRefShouldInheritMappings() { - AggregateFunctionSpec fn = new AggregateFunctionSpec(); - fn.setName("get-data"); - fn.setDescription("Get data"); - fn.getMappings().add(new StepOutputMappingSpec("result", "$.lookup.data")); - - Map map = Map.of("data.get-data", fn); - - RestServerOperationSpec op = new RestServerOperationSpec(); - op.setRef("data.get-data"); - - resolver.resolveRestOperationRef(op, map); - - assertEquals(1, op.getMappings().size()); - assertEquals("result", op.getMappings().get(0).getTargetName()); - assertEquals("$.lookup.data", op.getMappings().get(0).getValue()); + assertNull(op.getCall(), "call should not be copied — resolved at runtime via AggregateFunction"); } // ── deriveHints ── diff --git a/src/test/java/io/naftiko/engine/consumes/http/ConsumesImportResolverTest.java b/src/test/java/io/naftiko/engine/consumes/http/ConsumesImportResolverTest.java index 3ffcf8e0..9ddd3387 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/ConsumesImportResolverTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/ConsumesImportResolverTest.java @@ -25,7 +25,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.spec.consumes.ClientSpec; import io.naftiko.spec.consumes.HttpClientSpec; import io.naftiko.spec.consumes.ImportedConsumesHttpSpec; 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 2d3d5bd8..dc285410 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.InputParameterSpec; import org.restlet.Request; diff --git a/src/test/java/io/naftiko/engine/consumes/http/HeadersTest.java b/src/test/java/io/naftiko/engine/consumes/http/HeadersTest.java index e530506a..2f53bcd4 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/HeadersTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/HeadersTest.java @@ -20,7 +20,7 @@ import org.junit.jupiter.api.Test; import org.restlet.Request; import org.restlet.data.Method; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.InputParameterSpec; import io.naftiko.spec.consumes.HttpClientSpec; 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 ecb1fd4e..140b56d8 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java @@ -21,7 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; -import io.naftiko.engine.Converter; +import io.naftiko.engine.util.Converter; import io.naftiko.spec.NaftikoSpec; import io.naftiko.util.VersionHelper; 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 05e82d7d..68d7c68b 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java @@ -21,7 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; -import io.naftiko.engine.Converter; +import io.naftiko.engine.util.Converter; import io.naftiko.spec.NaftikoSpec; import io.naftiko.util.VersionHelper; diff --git a/src/test/java/io/naftiko/engine/exposes/OutputMappingExtensionTest.java b/src/test/java/io/naftiko/engine/exposes/OutputMappingExtensionTest.java index df3262dc..8dd27b90 100644 --- a/src/test/java/io/naftiko/engine/exposes/OutputMappingExtensionTest.java +++ b/src/test/java/io/naftiko/engine/exposes/OutputMappingExtensionTest.java @@ -21,7 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.OutputParameterSpec; public class OutputMappingExtensionTest { 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 258abb67..be630abd 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; +import io.naftiko.engine.aggregates.AggregateFunction; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.exposes.McpServerSpec; import io.naftiko.spec.exposes.McpServerToolSpec; @@ -52,8 +53,13 @@ void refShouldResolveCallFromAggregateFunction() throws Exception { 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()); + // call is no longer copied to the tool spec — it is resolved at runtime + assertNull(tool.getCall(), "call should not be on tool spec — delegated to aggregate"); + + // Verify call is available via the runtime aggregate function + AggregateFunction fn = capability.lookupFunction(tool.getRef()); + assertNotNull(fn.getCall(), "call should be available on the aggregate function"); + assertEquals("weather-api.get-forecast", fn.getCall().getOperation()); } @Test @@ -63,8 +69,14 @@ void refShouldResolveInputParametersFromFunction() throws Exception { McpServerSpec serverSpec = adapter.getMcpServerSpec(); McpServerToolSpec tool = serverSpec.getTools().get(0); - assertEquals(1, tool.getInputParameters().size()); - assertEquals("location", tool.getInputParameters().get(0).getName()); + // inputParameters are no longer copied to the tool spec + assertTrue(tool.getInputParameters().isEmpty(), + "inputParameters should not be on tool spec — delegated to aggregate"); + + // Verify inputParameters are available via the runtime aggregate function + AggregateFunction fn = capability.lookupFunction(tool.getRef()); + assertEquals(1, fn.getInputParameters().size()); + assertEquals("location", fn.getInputParameters().get(0).getName()); } @Test @@ -95,6 +107,8 @@ void restOperationRefShouldInheritNameAndDescription() throws Exception { assertEquals("POST", op.getMethod()); assertEquals("get-forecast", op.getName()); assertEquals("Fetch current weather forecast for a location.", op.getDescription()); + // call is no longer copied — resolved at runtime + assertNull(op.getCall(), "call should not be on operation spec — delegated to aggregate"); } // ── Semantics → hints derivation ── diff --git a/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java index 209b154f..4c983c83 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/AggregateSharedMockIntegrationTest.java @@ -15,19 +15,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.File; -import java.util.Map; import org.junit.jupiter.api.Test; import org.restlet.Request; import org.restlet.Response; import org.restlet.data.Method; +import org.restlet.data.Reference; import org.restlet.data.Status; 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.engine.aggregates.AggregateFunction; import io.naftiko.engine.exposes.mcp.McpServerAdapter; import io.naftiko.engine.exposes.mcp.ProtocolDispatcher; import io.naftiko.spec.NaftikoSpec; @@ -61,19 +62,20 @@ void aggregateMockShouldReturnSamePayloadForMcpAndRest() throws Exception { RestServerAdapter restAdapter = (RestServerAdapter) capability.getServerAdapters().get(1); RestServerSpec restSpec = (RestServerSpec) restAdapter.getSpec(); - ResourceRestlet restlet = new ResourceRestlet(capability, restSpec, - restSpec.getResources().get(0)); RestServerOperationSpec restOperation = restSpec.getResources().get(0).getOperations().get(0); - assertEquals(2, restOperation.getOutputParameters().size(), - "REST operation should inherit two aggregate output parameters"); - - assertTrue(restlet.canBuildMockResponse(restOperation), - "REST operation should inherit aggregate mock output parameters"); + // Output parameters are no longer copied — they live on the aggregate function + AggregateFunction fn = capability.lookupFunction(restOperation.getRef()); + assertNotNull(fn, "Aggregate function should be resolvable from ref"); + assertEquals(2, fn.getOutputParameters().size(), + "Aggregate function should have two output parameters"); - Request request = new Request(Method.GET, "http://localhost/hello?name=Nina"); + // Exercise REST path through the normal flow (delegating to aggregate function) + ResourceRestlet restlet = new ResourceRestlet(capability, restSpec, + restSpec.getResources().get(0)); + Request request = new Request(Method.GET, new Reference("http://localhost/hello?name=Nina")); Response response = new Response(request); - restlet.sendMockResponse(restOperation, response, Map.of("name", "Nina")); + restlet.handle(request, response); assertEquals(Status.SUCCESS_OK, response.getStatus()); JsonNode restPayload = JSON.readTree(response.getEntity().getText()); 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 bfa85f90..00abbe71 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/HeaderQueryIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/HeaderQueryIntegrationTest.java @@ -95,7 +95,7 @@ public void testHeadersAndQueryPopulation() throws Exception { new Request(Method.POST, "http://example.com/items"); // Apply client-level input parameters to the request using Resolver - io.naftiko.engine.Resolver.resolveInputParametersToRequest(helperReq, + io.naftiko.engine.util.Resolver.resolveInputParametersToRequest(helperReq, clientAdapter.getHttpClientSpec().getInputParameters(), params); String apiKey = helperReq.getHeaders().getFirstValue("X-API-Key", true); diff --git a/src/test/java/io/naftiko/engine/exposes/rest/OperationsRestletTest.java b/src/test/java/io/naftiko/engine/exposes/rest/OperationsRestletTest.java index 89945b12..9c3e1822 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/OperationsRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/OperationsRestletTest.java @@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; -import io.naftiko.engine.Resolver; +import io.naftiko.engine.util.Resolver; import java.util.HashMap; import java.util.Map; diff --git a/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java b/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java index cd5ac3ec..e276d33f 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java @@ -38,8 +38,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; -import io.naftiko.engine.Resolver; import io.naftiko.engine.exposes.OperationStepExecutor; +import io.naftiko.engine.util.Resolver; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.exposes.RestServerOperationSpec; diff --git a/src/test/java/io/naftiko/engine/BindingResolverTest.java b/src/test/java/io/naftiko/engine/util/BindingResolverTest.java similarity index 99% rename from src/test/java/io/naftiko/engine/BindingResolverTest.java rename to src/test/java/io/naftiko/engine/util/BindingResolverTest.java index 534a8767..a9a17734 100644 --- a/src/test/java/io/naftiko/engine/BindingResolverTest.java +++ b/src/test/java/io/naftiko/engine/util/BindingResolverTest.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/src/test/java/io/naftiko/engine/ConverterTest.java b/src/test/java/io/naftiko/engine/util/ConverterTest.java similarity index 99% rename from src/test/java/io/naftiko/engine/ConverterTest.java rename to src/test/java/io/naftiko/engine/util/ConverterTest.java index f2f9ef3b..ca1fbad8 100644 --- a/src/test/java/io/naftiko/engine/ConverterTest.java +++ b/src/test/java/io/naftiko/engine/util/ConverterTest.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/src/test/java/io/naftiko/engine/LookupExecutorTest.java b/src/test/java/io/naftiko/engine/util/LookupExecutorTest.java similarity index 99% rename from src/test/java/io/naftiko/engine/LookupExecutorTest.java rename to src/test/java/io/naftiko/engine/util/LookupExecutorTest.java index 78f8e840..41f52a92 100644 --- a/src/test/java/io/naftiko/engine/LookupExecutorTest.java +++ b/src/test/java/io/naftiko/engine/util/LookupExecutorTest.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; diff --git a/src/test/java/io/naftiko/engine/ResolverTest.java b/src/test/java/io/naftiko/engine/util/ResolverTest.java similarity index 99% rename from src/test/java/io/naftiko/engine/ResolverTest.java rename to src/test/java/io/naftiko/engine/util/ResolverTest.java index 7567b6c1..bd089ec4 100644 --- a/src/test/java/io/naftiko/engine/ResolverTest.java +++ b/src/test/java/io/naftiko/engine/util/ResolverTest.java @@ -11,7 +11,7 @@ * or implied. See the License for the specific language governing permissions and limitations under * the License. */ -package io.naftiko.engine; +package io.naftiko.engine.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/src/test/java/io/naftiko/tutorial/Step10ShipyardMcpClientIntegrationTest.java b/src/test/java/io/naftiko/tutorial/Step10ShipyardMcpClientIntegrationTest.java index 7eade8e0..e18c301d 100644 --- a/src/test/java/io/naftiko/tutorial/Step10ShipyardMcpClientIntegrationTest.java +++ b/src/test/java/io/naftiko/tutorial/Step10ShipyardMcpClientIntegrationTest.java @@ -31,7 +31,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.naftiko.Capability; -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.engine.exposes.skill.SkillServerAdapter; import io.naftiko.spec.NaftikoSpec; diff --git a/src/test/java/io/naftiko/tutorial/Step5ShipyardMcpClientIntegrationTest.java b/src/test/java/io/naftiko/tutorial/Step5ShipyardMcpClientIntegrationTest.java index 99d61f13..b8efe92d 100644 --- a/src/test/java/io/naftiko/tutorial/Step5ShipyardMcpClientIntegrationTest.java +++ b/src/test/java/io/naftiko/tutorial/Step5ShipyardMcpClientIntegrationTest.java @@ -24,8 +24,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; - -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.consumes.ClientSpec; import io.naftiko.spec.consumes.HttpClientSpec; @@ -56,7 +55,7 @@ * *

Setup steps at test start:

*
    - *
  1. Patch both bind {@code location}s to an absolute URI so the {@link io.naftiko.engine.BindingResolver} + *
  2. Patch both bind {@code location}s to an absolute URI so the {@link io.naftiko.engine.util.BindingResolver} * finds the secrets file regardless of Maven CWD.
  3. *
  4. Manually resolve imported consumes via {@link ConsumesImportResolver} using the * tutorial directory as {@code capabilityDir}. This populates the consumes list with diff --git a/src/test/java/io/naftiko/tutorial/Step6ShipyardMcpClientIntegrationTest.java b/src/test/java/io/naftiko/tutorial/Step6ShipyardMcpClientIntegrationTest.java index e12c5b1f..ac39930f 100644 --- a/src/test/java/io/naftiko/tutorial/Step6ShipyardMcpClientIntegrationTest.java +++ b/src/test/java/io/naftiko/tutorial/Step6ShipyardMcpClientIntegrationTest.java @@ -24,8 +24,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; - -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.consumes.ClientSpec; import io.naftiko.spec.consumes.HttpClientSpec; diff --git a/src/test/java/io/naftiko/tutorial/Step7ShipyardMcpClientIntegrationTest.java b/src/test/java/io/naftiko/tutorial/Step7ShipyardMcpClientIntegrationTest.java index 2075c708..787c40f2 100644 --- a/src/test/java/io/naftiko/tutorial/Step7ShipyardMcpClientIntegrationTest.java +++ b/src/test/java/io/naftiko/tutorial/Step7ShipyardMcpClientIntegrationTest.java @@ -24,8 +24,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; - -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.consumes.ClientSpec; import io.naftiko.spec.consumes.HttpClientSpec; diff --git a/src/test/java/io/naftiko/tutorial/Step8ShipyardMcpClientIntegrationTest.java b/src/test/java/io/naftiko/tutorial/Step8ShipyardMcpClientIntegrationTest.java index 4abc3401..4d002b62 100644 --- a/src/test/java/io/naftiko/tutorial/Step8ShipyardMcpClientIntegrationTest.java +++ b/src/test/java/io/naftiko/tutorial/Step8ShipyardMcpClientIntegrationTest.java @@ -31,7 +31,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.naftiko.Capability; -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.engine.exposes.skill.SkillServerAdapter; import io.naftiko.spec.NaftikoSpec; diff --git a/src/test/java/io/naftiko/tutorial/Step9ShipyardMcpClientIntegrationTest.java b/src/test/java/io/naftiko/tutorial/Step9ShipyardMcpClientIntegrationTest.java index e4de58cd..35f15b36 100644 --- a/src/test/java/io/naftiko/tutorial/Step9ShipyardMcpClientIntegrationTest.java +++ b/src/test/java/io/naftiko/tutorial/Step9ShipyardMcpClientIntegrationTest.java @@ -32,7 +32,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.naftiko.Capability; -import io.naftiko.engine.ConsumesImportResolver; +import io.naftiko.engine.consumes.ConsumesImportResolver; import io.naftiko.engine.exposes.ServerAdapter; import io.naftiko.engine.exposes.skill.SkillServerAdapter; import io.naftiko.spec.NaftikoSpec; From 1137888c9aad62f17efefedc57198a85a779ff8f Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:32:41 -0400 Subject: [PATCH 4/4] chore: fix schema paths in aggregate fixtures and ignore .github/node_modules fix: use correct schema version in forecast-aggregate example fix: use correct schema version in test fixtures feat: add MockOutputParameter to schema for mock aggregate functions --- .gitignore | 1 + .../schemas/examples/forecast-aggregate.yml | 2 +- .../resources/schemas/naftiko-schema.json | 43 +++++++++++++++++++ .../resources/aggregates/aggregate-basic.yaml | 4 +- .../aggregates/aggregate-hints-override.yaml | 4 +- .../aggregates/aggregate-invalid-ref.yaml | 4 +- .../aggregates/aggregate-shared-mock.yaml | 6 +-- .../rules/spectral-semantics-consistent.yaml | 2 +- .../spectral-semantics-inconsistent.yaml | 2 +- 9 files changed, 55 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 9e8f380a..3e756842 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.vscode /target dependency-reduced-pom.xml +/.github/node_modules diff --git a/src/main/resources/schemas/examples/forecast-aggregate.yml b/src/main/resources/schemas/examples/forecast-aggregate.yml index a5724058..9a7bef02 100644 --- a/src/main/resources/schemas/examples/forecast-aggregate.yml +++ b/src/main/resources/schemas/examples/forecast-aggregate.yml @@ -1,6 +1,6 @@ # yaml-language-server: $schema=../naftiko-schema.json --- -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" info: label: "Weather Forecast (Aggregate)" description: > diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index 6eda1791..64bce31e 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -566,6 +566,34 @@ } ] }, + "MockOutputParameter": { + "type": "object", + "description": "Named output parameter with a static or template value for mock mode (no call or steps). The value field supports Mustache templates resolved against input parameters at runtime.", + "properties": { + "name": { + "$ref": "#/$defs/IdentifierExtended", + "description": "Name of the output field in the returned payload." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean" + ] + }, + "value": { + "type": "string", + "description": "Static value or Mustache template resolved against input parameters at runtime." + } + }, + "required": [ + "name", + "type", + "value" + ], + "additionalProperties": false + }, "Hostname": { "type": "string", "description": "Valid hostname", @@ -844,6 +872,21 @@ } } } + }, + { + "required": [ + "outputParameters" + ], + "type": "object", + "properties": { + "outputParameters": { + "type": "array", + "items": { + "$ref": "#/$defs/MockOutputParameter" + }, + "minItems": 1 + } + } } ], "additionalProperties": false diff --git a/src/test/resources/aggregates/aggregate-basic.yaml b/src/test/resources/aggregates/aggregate-basic.yaml index 5d263221..913ae213 100644 --- a/src/test/resources/aggregates/aggregate-basic.yaml +++ b/src/test/resources/aggregates/aggregate-basic.yaml @@ -1,6 +1,6 @@ -# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +# yaml-language-server: $schema=../../../main/resources/schemas/naftiko-schema.json --- -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" info: label: "Aggregate Basic Test" description: "Test capability for aggregate function ref resolution" diff --git a/src/test/resources/aggregates/aggregate-hints-override.yaml b/src/test/resources/aggregates/aggregate-hints-override.yaml index 1b8b2d77..78fe5ae5 100644 --- a/src/test/resources/aggregates/aggregate-hints-override.yaml +++ b/src/test/resources/aggregates/aggregate-hints-override.yaml @@ -1,6 +1,6 @@ -# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +# yaml-language-server: $schema=../../../main/resources/schemas/naftiko-schema.json --- -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" info: label: "Aggregate Hints Override Test" description: "Test capability for semantics-to-hints derivation with override" diff --git a/src/test/resources/aggregates/aggregate-invalid-ref.yaml b/src/test/resources/aggregates/aggregate-invalid-ref.yaml index 2cd9d9c2..621117a3 100644 --- a/src/test/resources/aggregates/aggregate-invalid-ref.yaml +++ b/src/test/resources/aggregates/aggregate-invalid-ref.yaml @@ -1,6 +1,6 @@ -# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +# yaml-language-server: $schema=../../../main/resources/schemas/naftiko-schema.json --- -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" info: label: "Aggregate Invalid Ref Test" description: "Test capability with an unknown aggregate ref" diff --git a/src/test/resources/aggregates/aggregate-shared-mock.yaml b/src/test/resources/aggregates/aggregate-shared-mock.yaml index bc1ae7b1..6f18e9dc 100644 --- a/src/test/resources/aggregates/aggregate-shared-mock.yaml +++ b/src/test/resources/aggregates/aggregate-shared-mock.yaml @@ -1,6 +1,6 @@ -# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +# yaml-language-server: $schema=../../../main/resources/schemas/naftiko-schema.json --- -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" info: label: "Aggregate Shared Mock Test" description: "Shared aggregate mock function consumed by MCP and REST refs" @@ -57,5 +57,3 @@ capability: in: "query" type: "string" description: "Name to greet" - - consumes: [] diff --git a/src/test/resources/rules/spectral-semantics-consistent.yaml b/src/test/resources/rules/spectral-semantics-consistent.yaml index 8aac13e3..e9ee43fa 100644 --- a/src/test/resources/rules/spectral-semantics-consistent.yaml +++ b/src/test/resources/rules/spectral-semantics-consistent.yaml @@ -1,4 +1,4 @@ -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" capability: aggregates: diff --git a/src/test/resources/rules/spectral-semantics-inconsistent.yaml b/src/test/resources/rules/spectral-semantics-inconsistent.yaml index 521371ac..6a99f5a2 100644 --- a/src/test/resources/rules/spectral-semantics-inconsistent.yaml +++ b/src/test/resources/rules/spectral-semantics-inconsistent.yaml @@ -1,4 +1,4 @@ -naftiko: "1.0.0-alpha1" +naftiko: "1.0.0-alpha2" capability: aggregates: