prepare-agent
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..0d8eb180
--- /dev/null
+++ b/src/main/java/io/naftiko/engine/AggregateRefResolver.java
@@ -0,0 +1,301 @@
+/**
+ * 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;
+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.
+ *
+ *
+ * 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 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());
+ 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 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.
+ */
+ AggregateFunctionSpec lookupFunction(String ref,
+ Map functionMap) {
+ AggregateFunctionSpec function = functionMap.get(ref);
+ if (function == null) {
+ throw new IllegalArgumentException(
+ "Unknown aggregate function ref: '" + ref
+ + "'. Available refs: " + functionMap.keySet());
+ }
+ return function;
+ }
+
+ /**
+ * Derive MCP tool hints from transport-neutral semantics.
+ *
+ *
+ * Mapping:
+ *
+ * - {@code safe: true} → {@code readOnly: true, destructive: false}
+ * - {@code safe: false/null} → {@code readOnly: false}
+ * - {@code idempotent} → copied directly
+ * - {@code cacheable} → not mapped (no MCP equivalent)
+ * - {@code openWorld} → not derived (MCP-specific)
+ *
+ */
+ McpToolHintsSpec deriveHints(SemanticsSpec semantics) {
+ McpToolHintsSpec hints = new McpToolHintsSpec();
+
+ if (Boolean.TRUE.equals(semantics.getSafe())) {
+ hints.setReadOnly(true);
+ hints.setDestructive(false);
+ } else if (Boolean.FALSE.equals(semantics.getSafe())) {
+ hints.setReadOnly(false);
+ }
+
+ if (semantics.getIdempotent() != null) {
+ hints.setIdempotent(semantics.getIdempotent());
+ }
+
+ // cacheable has no MCP equivalent — not mapped
+ // openWorld is MCP-specific — not derived from semantics
+
+ return hints;
+ }
+
+ /**
+ * Merge derived hints with explicit tool-level overrides. Each non-null explicit field wins over
+ * the derived value.
+ *
+ * @param derived Hints derived from semantics (never null)
+ * @param explicit Explicit tool-level hints (may be null)
+ * @return Merged hints
+ */
+ McpToolHintsSpec mergeHints(McpToolHintsSpec derived, McpToolHintsSpec explicit) {
+ if (explicit == null) {
+ return derived;
+ }
+
+ // Each explicit non-null field overrides the derived value
+ if (explicit.getReadOnly() != null) {
+ derived.setReadOnly(explicit.getReadOnly());
+ }
+ if (explicit.getDestructive() != null) {
+ derived.setDestructive(explicit.getDestructive());
+ }
+ if (explicit.getIdempotent() != null) {
+ derived.setIdempotent(explicit.getIdempotent());
+ }
+ if (explicit.getOpenWorld() != null) {
+ derived.setOpenWorld(explicit.getOpenWorld());
+ }
+
+ return derived;
+ }
+
+}
diff --git a/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java
new file mode 100644
index 00000000..74c388f5
--- /dev/null
+++ b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java
@@ -0,0 +1,120 @@
+/**
+ * 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.StepOutputMappingSpec;
+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/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/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 f22505ef..815c2d4b 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..dfcc0d42
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java
@@ -0,0 +1,485 @@
+/**
+ * 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;
+import io.naftiko.spec.exposes.StepOutputMappingSpec;
+
+/**
+ * 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 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();
+ 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());
+ }
+
+ @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
+ 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 23dbaa22..fab91417 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java
@@ -38,7 +38,7 @@ public class CsvIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
// Load the CSV capability from test resources
- String resourcePath = "src/test/resources/csv-capability.yaml";
+ String resourcePath = "src/test/resources/formats/csv-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "CSV capability test file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java b/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java
index e6715a8e..2d3d5bd8 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java
@@ -39,7 +39,7 @@ public class ForwardValueFieldTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/http-forward-value-capability.yaml";
+ String resourcePath = "src/test/resources/http/http-forward-value-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "Capability file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java
index 5bdb6829..75697e4d 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java
@@ -36,7 +36,7 @@ public class HtmlIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/html-capability.yaml";
+ String resourcePath = "src/test/resources/formats/html-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "HTML capability test file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java
index ca2e1c6b..54fc0fa5 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java
@@ -36,7 +36,7 @@ public class MarkdownIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/markdown-capability.yaml";
+ String resourcePath = "src/test/resources/formats/markdown-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "Markdown capability test file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java
index 43104b49..53a3ba42 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java
@@ -40,7 +40,7 @@ public class ProtobufIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
// Load the Protobuf capability from test resources
- String resourcePath = "src/test/resources/proto-capability.yaml";
+ String resourcePath = "src/test/resources/formats/proto-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(),
diff --git a/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java
index 71a525d6..3a64f9bb 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java
@@ -42,7 +42,7 @@ public class XmlIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
// Load the XML capability from test resources
- String resourcePath = "src/test/resources/xml-capability.yaml";
+ String resourcePath = "src/test/resources/formats/xml-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "XML capability test file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java
index d166e8c3..9d216399 100644
--- a/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java
@@ -41,7 +41,7 @@ public class YamlIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
// Load the YAML capability from test resources
- String resourcePath = "src/test/resources/yaml-capability.yaml";
+ String resourcePath = "src/test/resources/formats/yaml-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "YAML capability test file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java
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 7f0f87f6..9d1d9b60 100644
--- a/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java
@@ -38,7 +38,7 @@ public class McpIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/mcp-capability.yaml";
+ String resourcePath = "src/test/resources/mcp/mcp-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "MCP capability test file should exist at " + resourcePath);
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java
index ab60ab02..51524864 100644
--- a/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java
@@ -41,7 +41,7 @@ public class McpToolHintsIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/mcp-hints-capability.yaml";
+ String resourcePath = "src/test/resources/mcp/mcp-hints-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(), "MCP hints capability test file should exist");
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java
index e2c8c6ad..0b423da4 100644
--- a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java
@@ -32,7 +32,7 @@ class ProtocolDispatcherCoverageTest {
@Test
void dispatchShouldReturnInternalErrorWhenRequestIsNull() throws Exception {
- ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml");
+ ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml");
ObjectNode response = dispatcher.dispatch(null);
@@ -43,7 +43,7 @@ void dispatchShouldReturnInternalErrorWhenRequestIsNull() throws Exception {
@Test
void initializeShouldAdvertiseOnlyToolsWhenNoResourcesOrPrompts() throws Exception {
- ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml");
+ ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml");
JsonNode response = dispatcher.dispatch(JSON.readTree(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"));
@@ -56,7 +56,7 @@ void initializeShouldAdvertiseOnlyToolsWhenNoResourcesOrPrompts() throws Excepti
@Test
void toolsCallUnknownToolShouldReturnInvalidParams() throws Exception {
- ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml");
+ ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml");
JsonNode response = dispatcher.dispatch(JSON.readTree("""
{
@@ -74,7 +74,7 @@ void toolsCallUnknownToolShouldReturnInvalidParams() throws Exception {
@Test
void resourcesAndPromptsInvalidParamsShouldReturnExpectedErrors() throws Exception {
ProtocolDispatcher dispatcher =
- dispatcherFrom("src/test/resources/mcp-resources-prompts-capability.yaml");
+ dispatcherFrom("src/test/resources/mcp/mcp-resources-prompts-capability.yaml");
JsonNode readNullParams = dispatcher.dispatch(JSON.readTree(
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"resources/read\"}"));
@@ -91,7 +91,7 @@ void resourcesAndPromptsInvalidParamsShouldReturnExpectedErrors() throws Excepti
@Test
void jsonRpcEnvelopeBuildersShouldHandleNullAndPresentIds() throws Exception {
- ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml");
+ ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml");
ObjectNode resultNoId = dispatcher.buildJsonRpcResult(null, JSON.createObjectNode());
assertEquals("2.0", resultNoId.path("jsonrpc").asText());
@@ -107,7 +107,7 @@ void jsonRpcEnvelopeBuildersShouldHandleNullAndPresentIds() throws Exception {
@Test
void toolsCallShouldReturnIsErrorResultOnUnexpectedExecutionFailure() throws Exception {
- ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml");
+ ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml");
JsonNode response = dispatcher.dispatch(JSON.readTree("""
{
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java
index 70add5a4..582ae8d3 100644
--- a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java
@@ -33,7 +33,7 @@ public class ProtocolDispatcherNegativeTest {
@BeforeEach
public void setUp() throws Exception {
mapper = new ObjectMapper();
- String resourcePath = "src/test/resources/mcp-resources-prompts-capability.yaml";
+ String resourcePath = "src/test/resources/mcp/mcp-resources-prompts-capability.yaml";
File file = new File(resourcePath);
ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java
index 8725b57c..2b4091b7 100644
--- a/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java
@@ -43,7 +43,7 @@ public class ResourcesPromptsIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/mcp-resources-prompts-capability.yaml";
+ String resourcePath = "src/test/resources/mcp/mcp-resources-prompts-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(),
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java
index 823f12d4..37440b0d 100644
--- a/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java
@@ -39,7 +39,7 @@ public class StdioIntegrationTest {
@BeforeEach
public void setUp() throws Exception {
- String resourcePath = "src/test/resources/mcp-stdio-capability.yaml";
+ String resourcePath = "src/test/resources/mcp/mcp-stdio-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists(),
@@ -217,7 +217,7 @@ public void testStdioHandlerEndToEnd() throws Exception {
@Test
public void testHttpTransportDefaultWhenNotSet() throws Exception {
// Load the original MCP capability (no transport field)
- String resourcePath = "src/test/resources/mcp-capability.yaml";
+ String resourcePath = "src/test/resources/mcp/mcp-capability.yaml";
File file = new File(resourcePath);
assertTrue(file.exists());
diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java
index 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/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/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/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-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/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: []
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