From c17bcbc72a403bb3d84cadcc390f1c4d4b55d1df Mon Sep 17 00:00:00 2001
From: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Date: Fri, 3 Apr 2026 21:01:59 -0400
Subject: [PATCH 1/4] feat: add aggregates with DDD-inspired domain functions
and ref resolution
Introduce aggregates[] on Capability for defining reusable domain functions
- Schema: Aggregate, AggregateFunction, Semantics definitions; ref on McpTool and ExposedOperation
- Engine: AggregateRefResolver merges ref fields and derives MCP hints from semantics
- Spectral: aggregate-semantics-consistency rule detects contradictions between semantics, MCP hints, and REST methods
- Tests: 25 unit + 11 integration + 3 Spectral tests
- Docs: Specification-Schema, FAQ, design-guidelines, AGENTS.md, SKILL.md, wrap-api-as-mcp
Closes #191
chore: organize test fixtures into subdirectories
feat: add aggregates with DDD-inspired domain functions and ref resolution
Introduce aggregates[] on Capability for defining reusable domain functions
- Schema: Aggregate, AggregateFunction, Semantics definitions; ref on McpTool and ExposedOperation
- Engine: AggregateRefResolver merges ref fields and derives MCP hints from semantics
- Spectral: aggregate-semantics-consistency rule detects contradictions between semantics, MCP hints, and REST methods
- Tests: 25 unit + 11 integration + 3 Spectral tests
- Docs: Specification-Schema, FAQ, design-guidelines, AGENTS.md, SKILL.md, wrap-api-as-mcp
Closes #191
chore: organize test fixtures into subdirectories
---
.agents/skills/naftiko-capability/SKILL.md | 30 +-
.../references/design-guidelines.md | 27 ++
.../references/wrap-api-as-mcp.md | 16 +-
AGENTS.md | 5 +
README.md | 1 +
src/main/java/io/naftiko/Capability.java | 5 +
.../naftiko/engine/AggregateRefResolver.java | 286 +++++++++++
.../naftiko/spec/AggregateFunctionSpec.java | 119 +++++
.../java/io/naftiko/spec/AggregateSpec.java | 54 +++
.../java/io/naftiko/spec/CapabilitySpec.java | 8 +
.../java/io/naftiko/spec/SemanticsSpec.java | 58 +++
.../spec/exposes/McpServerToolSpec.java | 11 +
.../spec/exposes/RestServerOperationSpec.java | 11 +
.../aggregate-semantics-consistency.js | 183 +++++++
src/main/resources/rules/naftiko-rules.yml | 26 +
.../schemas/examples/forecast-aggregate.yml | 87 ++++
.../resources/schemas/naftiko-schema.json | 168 ++++++-
src/main/resources/wiki/FAQ.md | 70 +++
src/main/resources/wiki/Home.md | 1 +
.../Specification-\342\200\220-Schema.md" | 198 +++++++-
.../engine/AggregateRefResolverTest.java | 446 ++++++++++++++++++
.../consumes/http/AvroIntegrationTest.java | 2 +-
.../consumes/http/CsvIntegrationTest.java | 2 +-
.../consumes/http/ForwardValueFieldTest.java | 2 +-
.../consumes/http/HtmlIntegrationTest.java | 2 +-
.../http/MarkdownIntegrationTest.java | 2 +-
.../http/ProtobufIntegrationTest.java | 2 +-
.../consumes/http/XmlIntegrationTest.java | 2 +-
.../consumes/http/YamlIntegrationTest.java | 2 +-
.../exposes/mcp/AggregateIntegrationTest.java | 211 +++++++++
.../mcp/JettyStreamableHandlerTest.java | 2 +-
.../exposes/mcp/McpIntegrationTest.java | 2 +-
.../mcp/McpToolHintsIntegrationTest.java | 2 +-
.../mcp/ProtocolDispatcherCoverageTest.java | 12 +-
.../mcp/ProtocolDispatcherNegativeTest.java | 2 +-
.../mcp/ResourcesPromptsIntegrationTest.java | 2 +-
.../exposes/mcp/StdioIntegrationTest.java | 4 +-
.../engine/exposes/mcp/ToolHandlerTest.java | 2 +-
.../rest/HeaderQueryIntegrationTest.java | 2 +-
.../exposes/rest/HttpBodyIntegrationTest.java | 2 +-
.../spec/NaftikoSpectralRulesetTest.java | 64 +++
.../resources/aggregates/aggregate-basic.yaml | 79 ++++
.../aggregates/aggregate-hints-override.yaml | 86 ++++
.../aggregates/aggregate-invalid-ref.yaml | 43 ++
.../{ => formats}/avro-capability.yaml | 0
.../{ => formats}/csv-capability.yaml | 0
.../{ => formats}/html-capability.yaml | 0
.../{ => formats}/markdown-capability.yaml | 0
.../{ => formats}/proto-capability.yaml | 0
.../{ => formats}/xml-capability.yaml | 0
.../{ => formats}/yaml-capability.yaml | 0
.../{ => http}/http-body-capability.yaml | 0
.../http-forward-value-capability.yaml | 0
.../http-header-query-capability.yaml | 0
.../resources/{ => mcp}/mcp-capability.yaml | 0
.../{ => mcp}/mcp-hints-capability.yaml | 0
.../mcp-resources-prompts-capability.yaml | 0
.../{ => mcp}/mcp-stdio-capability.yaml | 0
...ool-handler-with-mustache-capability.yaml} | 0
.../rules/spectral-semantics-consistent.yaml | 60 +++
.../spectral-semantics-inconsistent.yaml | 60 +++
61 files changed, 2415 insertions(+), 46 deletions(-)
create mode 100644 src/main/java/io/naftiko/engine/AggregateRefResolver.java
create mode 100644 src/main/java/io/naftiko/spec/AggregateFunctionSpec.java
create mode 100644 src/main/java/io/naftiko/spec/AggregateSpec.java
create mode 100644 src/main/java/io/naftiko/spec/SemanticsSpec.java
create mode 100644 src/main/resources/rules/functions/aggregate-semantics-consistency.js
create mode 100644 src/main/resources/schemas/examples/forecast-aggregate.yml
create mode 100644 src/test/java/io/naftiko/engine/AggregateRefResolverTest.java
create mode 100644 src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java
create mode 100644 src/test/resources/aggregates/aggregate-basic.yaml
create mode 100644 src/test/resources/aggregates/aggregate-hints-override.yaml
create mode 100644 src/test/resources/aggregates/aggregate-invalid-ref.yaml
rename src/test/resources/{ => formats}/avro-capability.yaml (100%)
rename src/test/resources/{ => formats}/csv-capability.yaml (100%)
rename src/test/resources/{ => formats}/html-capability.yaml (100%)
rename src/test/resources/{ => formats}/markdown-capability.yaml (100%)
rename src/test/resources/{ => formats}/proto-capability.yaml (100%)
rename src/test/resources/{ => formats}/xml-capability.yaml (100%)
rename src/test/resources/{ => formats}/yaml-capability.yaml (100%)
rename src/test/resources/{ => http}/http-body-capability.yaml (100%)
rename src/test/resources/{ => http}/http-forward-value-capability.yaml (100%)
rename src/test/resources/{ => http}/http-header-query-capability.yaml (100%)
rename src/test/resources/{ => mcp}/mcp-capability.yaml (100%)
rename src/test/resources/{ => mcp}/mcp-hints-capability.yaml (100%)
rename src/test/resources/{ => mcp}/mcp-resources-prompts-capability.yaml (100%)
rename src/test/resources/{ => mcp}/mcp-stdio-capability.yaml (100%)
rename src/test/resources/{tool-handler-with-mustache-capability.yaml => mcp/mcp-tool-handler-with-mustache-capability.yaml} (100%)
create mode 100644 src/test/resources/rules/spectral-semantics-consistent.yaml
create mode 100644 src/test/resources/rules/spectral-semantics-inconsistent.yaml
diff --git a/.agents/skills/naftiko-capability/SKILL.md b/.agents/skills/naftiko-capability/SKILL.md
index cc257d20..475b42c8 100644
--- a/.agents/skills/naftiko-capability/SKILL.md
+++ b/.agents/skills/naftiko-capability/SKILL.md
@@ -25,9 +25,10 @@ is a single YAML file validated against the Naftiko JSON Schema (v1.0.0-alpha1).
Key spec objects you will work with:
- **Info** — metadata: label, description, tags, stakeholders
-- **Capability** — root technical config; contains `exposes` and `consumes`
+- **Capability** — root technical config; contains `exposes`, `consumes`, and `aggregates`
- **Consumes** — HTTP client adapter: baseUri, namespace, resources, operations
- **Exposes** — server adapter: REST (`type: rest`), MCP (`type: mcp`), or Skill (`type: skill`)
+- **Aggregates** — DDD-inspired domain building blocks; each aggregate groups reusable functions under a namespace. Tools and operations reference functions via `ref`
- **Binds** — variable injection from file (dev) or runtime (prod)
- **Namespace** — unique identifier linking exposes to consumes via routing
@@ -50,6 +51,7 @@ for *how*.
| "I want to proxy an API today and encapsulate it incrementally" | Read `references/proxy-then-customize.md` |
| "I want to chain multiple HTTP calls to consumed APIs and expose the result into a single REST operation" | Read `references/chain-api-calls.md` |
| "I need to go from local test credentials to production secrets" | Read `references/dev-to-production.md` |
+| "I want to define a domain function once and expose it via both REST and MCP" | Use `aggregates` with `ref` — read `references/design-guidelines.md` (Aggregate Design Guidelines) |
| "I want to prototype a tool or endpoint before the backend exists" or "I want to return static or dynamic mock data" | Read `references/mock-capability.md` |
| "I want to build a full-featured capability that does all of the above" | Read all stories in order, then use `assets/capability-example.yml` as structural reference |
| "I have a YAML validation error" | Run `scripts/lint-capability.sh` — see **Lint workflow** below |
@@ -121,13 +123,15 @@ before writing any mock output parameters.
replace `value` with `mapping`, and add `call` or `steps` — the
exposed contract does not change.
and `trustedHeaders` (at least one entry).
-10. MCP tools must have `name` and `description`. MCP tool input parameters
- must have `name`, `type`, and `description`. Tools may declare optional
+10. MCP tools must have `name` and `description` (unless using `ref`, in which
+ case they are inherited from the referenced aggregate function). MCP tool input
+ parameters must have `name`, `type`, and `description`. Tools may declare optional
`hints` (readOnly, destructive, idempotent, openWorld) — these map to
MCP `ToolAnnotations` on the wire.
-11. ExposedOperation supports exactly two modes (oneOf): simple (`call` +
- optional `with`) or orchestrated (`steps` + optional `mappings`). Never
- mix fields from both modes.
+11. ExposedOperation supports three modes (oneOf): simple (`call` +
+ optional `with`), orchestrated (`steps` + optional `mappings`), or
+ ref (`ref` to an aggregate function). Never mix fields from
+ incompatible modes.
12. Do not modify `scripts/lint-capability.sh` unless explicitly asked —
it wraps Spectral with the correct ruleset and flags.
13. Do not add properties that are not in the JSON Schema — the schema
@@ -141,9 +145,19 @@ before writing any mock output parameters.
16. Do not prefix variable names with the capability, namespace, or
resource name — variables are already scoped to their context.
Redundant prefixes reduce readability without adding disambiguation.
-17. In mock mode, use `value` on output parameters — never `const`.
+17. When using `ref` on MCP tools or REST operations, the `ref` value must
+ follow the format `{aggregate-namespace}.{function-name}` and resolve
+ to an existing function in the capability's `aggregates` array.
+18. Do not chain `ref` through multiple levels of aggregates — `ref`
+ resolves to a function in a single aggregate, not transitively.
+19. Aggregate functions can declare `semantics` (safe, idempotent, cacheable).
+ When exposed via MCP, the engine auto-derives `hints` from semantics.
+ Explicit `hints` on the MCP tool override derived values.
+20. Do not duplicate a full function definition inline on both MCP tools
+ and REST operations — use `aggregates` + `ref` instead.
+21. In mock mode, use `value` on output parameters — never `const`.
`const` is a JSON Schema keyword retained for validation and linting
only; it has no effect at runtime.
-18. In mock mode, Mustache templates in `value` fields resolve only against
+22. In mock mode, Mustache templates in `value` fields resolve only against
top-level input parameter names. Do not reference `with`-remapped
consumed parameter names — those are not in scope for output resolution.
\ No newline at end of file
diff --git a/.agents/skills/naftiko-capability/references/design-guidelines.md b/.agents/skills/naftiko-capability/references/design-guidelines.md
index 7167d0ae..7a5eae62 100644
--- a/.agents/skills/naftiko-capability/references/design-guidelines.md
+++ b/.agents/skills/naftiko-capability/references/design-guidelines.md
@@ -133,6 +133,33 @@ Do not mix fields from both modes in one operation/tool.
- Do not expose internal IDs unless they are necessary and meaningful for consumers.
- Avoid returning massive nested objects if only a few fields are needed.
+## Aggregate design guidelines (DDD-inspired)
+
+Aggregates borrow from [Domain-Driven Design](https://en.wikipedia.org/wiki/Domain-driven_design#Building_blocks): each aggregate groups related functions under a namespace that represents a single domain concept (the **Aggregate Root**). Functions within the aggregate are the operations that agents and clients can invoke.
+
+### Define aggregate boundaries around domain concepts
+
+- One aggregate = one domain concept (e.g., `forecast`, `ticket`, `user-profile`).
+- Functions within an aggregate should operate on the same domain data — if a function feels unrelated, it likely belongs in a different aggregate.
+- Keep function names intention-revealing and adapter-neutral: `get-forecast`, not `mcp-get-forecast` or `rest-forecast-query`.
+
+### Use `ref` to share functions across adapters
+
+- When the same domain operation is exposed via REST *and* MCP, define it once in `aggregates` and reference it with `ref` from both adapters.
+- Override only the adapter-specific fields at the tool/operation level (e.g., `method` for REST, `hints` for MCP).
+- Do not duplicate the full function definition inline when `ref` can carry it.
+
+### Use `semantics` as the single source of behavioral truth
+
+- Declare `safe`, `idempotent`, and `cacheable` on the aggregate function — they describe the domain behavior, not a transport detail.
+- Let the engine derive MCP `hints` from semantics automatically. Override hints only when the derived values are insufficient (e.g., setting `openWorld`).
+- Do not set `semantics` on functions that are only exposed via REST — REST has its own semantic model via HTTP methods.
+
+### Keep aggregates lean
+
+- Start with functions only (the "functions-first" approach). Entities, events, and other DDD stereotypes may be added in future schema versions.
+- Avoid creating an aggregate for a single function that is only used in one place — aggregates pay off when sharing across adapters or when grouping related operations.
+
## Secret management (dev → prod)
- Use `binds` for any sensitive values or environment-dependent configuration.
diff --git a/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md b/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md
index 63bcd1db..c502d5b3 100644
--- a/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md
+++ b/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md
@@ -76,6 +76,14 @@ When wrapping an API as MCP, design in this order:
- simple mode: inline typed output parameters (`MappedOutputParameter`)
- orchestrated mode: named typed outputs (`OrchestratedOutputParameter`) plus `mappings`
+4) If the same operation is also exposed via REST, consider using **aggregates**:
+
+- Define the function once in `capability.aggregates[]` (DDD Aggregate pattern)
+- Reference it from MCP tools with `ref: {namespace}.{function-name}`
+- Declare `semantics` (safe, idempotent, cacheable) on the function — the engine auto-derives MCP `hints`
+- Override only MCP-specific fields on the tool (e.g., explicit `hints` for `openWorld`)
+- `name` and `description` are inherited from the function unless overridden
+
## Constraints (aligned with schema + rules)
### Global constraints
@@ -95,12 +103,15 @@ When wrapping an API as MCP, design in this order:
For each MCP tool:
1. `name` (kebab-case / IdentifierKebab) is required and must be stable (used as the MCP tool name).
+ When using `ref`, `name` is optional — inherited from the aggregate function.
2. `description` is required (agent discovery depends on it).
+ When using `ref`, `description` is optional — inherited from the aggregate function.
3. `hints` is optional — declares behavioral hints mapped to MCP `ToolAnnotations`:
- `readOnly` (bool) — tool does not modify its environment (default: false)
- `destructive` (bool) — tool may perform destructive updates (default: true, meaningful only when readOnly is false)
- `idempotent` (bool) — repeating the call has no additional effect (default: false, meaningful only when readOnly is false)
- `openWorld` (bool) — tool interacts with external entities (default: true)
+ When using `ref` with `semantics` on the function, hints are auto-derived (safe→readOnly/destructive, idempotent→idempotent). Explicit hints override derived values.
4. If tool is simple:
- must define `call: {namespace}.{operationName}`
- may define `with`
@@ -109,7 +120,10 @@ For each MCP tool:
- must define `steps` (min 1), each step has `name`
- may define `mappings`
- `outputParameters` must use orchestrated output parameter objects (named + typed)
-6. Tool `inputParameters`:
+6. If tool uses `ref`:
+ - must define `ref: {namespace}.{function-name}` pointing to an aggregate function
+ - all other fields are optional — inherited from the function, explicit values override
+7. Tool `inputParameters`:
- each parameter must have `name`, `type`, `description`
- set `required: false` explicitly for optional params (default is true)
diff --git a/AGENTS.md b/AGENTS.md
index e9c297d2..dc06ce67 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,6 +88,9 @@ When designing or modifying a Capability:
- Keep the [Naftiko Specification](src/main/resources/schemas/naftiko-schema.json) and the [Naftiko Rules](src/main/resources/rules/naftiko-rules.yml) as first-class citizens — the schema enforces structure, the rules enforce cross-object consistency, quality, and security
- Look at `src/main/resources/schemas/examples/` for patterns before writing new capabilities
- When renaming a consumed field for a lookup `match`, also add a `ConsumedOutputParameter` on the consumed operation to map the raw field name to a kebab-case name — otherwise the lookup has nothing to match against
+- Use `aggregates` to define reusable domain functions when the same operation is exposed through multiple adapters (REST and MCP) — this follows the DDD Aggregate pattern: one definition, multiple projections
+- Declare `semantics` (safe, idempotent, cacheable) on aggregate functions to describe domain behavior — the engine derives MCP `hints` automatically
+- Override only adapter-specific fields when using `ref` (e.g., `method` for REST, `hints` for MCP) — let the rest be inherited from the function
**Don't:**
- Expose an `inputParameter` that is not used in any step
@@ -103,6 +106,8 @@ When designing or modifying a Capability:
- Use `MappedOutputParameter` (with `mapping`, no `name`) when the tool/operation uses `steps` — use `OrchestratedOutputParameter` (with `name`, no `mapping`) instead
- Use typed objects for lookup step `outputParameters` — they are plain string arrays of field names to extract (e.g. `- "fullName"`)
- Put a `path` property on an `ExposedOperation` — extract multi-step operations with a different path into their own `ExposedResource`
+- Duplicate a full function definition inline on both MCP tools and REST operations — use `aggregates` + `ref` instead
+- Chain `ref` through multiple levels of aggregates — `ref` resolves to a function in a single aggregate, not transitively
## Contribution Workflow
diff --git a/README.md b/README.md
index d4dfaa29..3d232c7c 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Each capability is a coarse piece of domain that consumes existing HTTP-based AP
| Data Format Conversion | Transform **Protobuf**, **XML**, **YAML**, **CSV**, **TSV**, **PSV**, **Avro**, **HTML**, and **Markdown** payloads into JSON |
| HTTP API Consumption | Connect to any HTTP-based API with built-in authentication support |
| Templating & Querying | Use **Mustache** templates and **JSONPath** expressions for flexible data mapping |
+| Domain-Driven Aggregates | Define reusable domain functions once, expose via multiple adapters — inspired by **DDD** Aggregate pattern |
| AI Native | Designed for Context Engineering and Agent Orchestration, making capabilities directly consumable by AI agents |
| Docker Native | Ships as a ready-to-run **Docker** container |
| Extensible | Open-source core extensible with new protocols and adapters |
diff --git a/src/main/java/io/naftiko/Capability.java b/src/main/java/io/naftiko/Capability.java
index f93d3704..3b9fd81b 100644
--- a/src/main/java/io/naftiko/Capability.java
+++ b/src/main/java/io/naftiko/Capability.java
@@ -21,6 +21,7 @@
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.naftiko.engine.AggregateRefResolver;
import io.naftiko.engine.BindingResolver;
import io.naftiko.engine.ConsumesImportResolver;
import io.naftiko.spec.ExecutionContext;
@@ -68,6 +69,10 @@ public Capability(NaftikoSpec spec, String capabilityDir) throws Exception {
importResolver.resolveImports(spec.getCapability().getConsumes(), capabilityDir);
}
+ // Resolve aggregate function refs before adapter initialization
+ AggregateRefResolver aggregateRefResolver = new AggregateRefResolver();
+ aggregateRefResolver.resolve(spec);
+
// Resolve bindings early for injection into adapters
BindingResolver bindingResolver = new BindingResolver();
ExecutionContext context = new ExecutionContext() {
diff --git a/src/main/java/io/naftiko/engine/AggregateRefResolver.java b/src/main/java/io/naftiko/engine/AggregateRefResolver.java
new file mode 100644
index 00000000..85b02df8
--- /dev/null
+++ b/src/main/java/io/naftiko/engine/AggregateRefResolver.java
@@ -0,0 +1,286 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.engine;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.logging.Level;
+import org.restlet.Context;
+import io.naftiko.spec.AggregateFunctionSpec;
+import io.naftiko.spec.AggregateSpec;
+import io.naftiko.spec.CapabilitySpec;
+import io.naftiko.spec.InputParameterSpec;
+import io.naftiko.spec.NaftikoSpec;
+import io.naftiko.spec.OutputParameterSpec;
+import io.naftiko.spec.SemanticsSpec;
+import io.naftiko.spec.exposes.McpServerSpec;
+import io.naftiko.spec.exposes.McpServerToolSpec;
+import io.naftiko.spec.exposes.McpToolHintsSpec;
+import io.naftiko.spec.exposes.OperationStepSpec;
+import io.naftiko.spec.exposes.RestServerOperationSpec;
+import io.naftiko.spec.exposes.RestServerResourceSpec;
+import io.naftiko.spec.exposes.RestServerSpec;
+import io.naftiko.spec.exposes.ServerSpec;
+
+/**
+ * Resolves aggregate function references ({@code ref}) in adapter units (MCP tools, REST
+ * operations). Runs at capability load time, before server startup.
+ *
+ *
+ * Resolution merges inherited fields from the referenced function into the adapter unit. Explicit
+ * adapter-local fields override inherited ones. For MCP tools, semantics are automatically derived
+ * into hints (with field-level override).
+ */
+public class AggregateRefResolver {
+
+ /**
+ * Resolve all {@code ref} fields across adapter units in the given spec. Modifies specs
+ * in-place.
+ *
+ * @param spec The root Naftiko spec to resolve
+ * @throws IllegalArgumentException if a ref target is unknown or a chained ref is detected
+ */
+ public void resolve(NaftikoSpec spec) {
+ CapabilitySpec capability = spec.getCapability();
+ if (capability == null || capability.getAggregates().isEmpty()) {
+ return;
+ }
+
+ // Build lookup map: "namespace.functionName" → AggregateFunctionSpec
+ Map functionMap = buildFunctionMap(capability);
+
+ // Resolve refs in all adapter units
+ for (ServerSpec serverSpec : capability.getExposes()) {
+ if (serverSpec instanceof McpServerSpec mcpSpec) {
+ for (McpServerToolSpec tool : mcpSpec.getTools()) {
+ if (tool.getRef() != null) {
+ resolveMcpToolRef(tool, functionMap);
+ }
+ }
+ } else if (serverSpec instanceof RestServerSpec restSpec) {
+ for (RestServerResourceSpec resource : restSpec.getResources()) {
+ for (RestServerOperationSpec op : resource.getOperations()) {
+ if (op.getRef() != null) {
+ resolveRestOperationRef(op, functionMap);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Build the lookup map of aggregate functions keyed by "namespace.functionName".
+ */
+ Map buildFunctionMap(CapabilitySpec capability) {
+ Map map = new HashMap<>();
+
+ for (AggregateSpec aggregate : capability.getAggregates()) {
+ for (AggregateFunctionSpec function : aggregate.getFunctions()) {
+ String key = aggregate.getNamespace() + "." + function.getName();
+ if (map.containsKey(key)) {
+ throw new IllegalArgumentException(
+ "Duplicate aggregate function ref: '" + key + "'");
+ }
+ map.put(key, function);
+ }
+ }
+
+ Context.getCurrentLogger().log(Level.INFO,
+ "Built aggregate function map with {0} entries", map.size());
+ return map;
+ }
+
+ /**
+ * Resolve a ref on an MCP tool. Merges inherited fields and derives hints from semantics.
+ */
+ void resolveMcpToolRef(McpServerToolSpec tool,
+ Map functionMap) {
+ AggregateFunctionSpec function = lookupFunction(tool.getRef(), functionMap);
+
+ // Merge name (function provides default, tool overrides)
+ if (tool.getName() == null || tool.getName().isEmpty()) {
+ tool.setName(function.getName());
+ }
+
+ // Merge description (function provides default, tool overrides)
+ if (tool.getDescription() == null || tool.getDescription().isEmpty()) {
+ tool.setDescription(function.getDescription());
+ }
+
+ // Merge call (function provides default, tool overrides)
+ if (tool.getCall() == null && function.getCall() != null) {
+ tool.setCall(function.getCall());
+ }
+
+ // Merge with (function provides default, tool overrides)
+ if (tool.getWith() == null && function.getWith() != null) {
+ tool.setWith(function.getWith());
+ }
+
+ // Merge steps (function provides default, tool overrides)
+ if (tool.getSteps().isEmpty() && !function.getSteps().isEmpty()) {
+ for (OperationStepSpec step : function.getSteps()) {
+ tool.getSteps().add(step);
+ }
+ }
+
+ // Merge inputParameters (function provides default, tool overrides)
+ if (tool.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) {
+ for (InputParameterSpec param : function.getInputParameters()) {
+ tool.getInputParameters().add(param);
+ }
+ }
+
+ // Merge outputParameters (function provides default, tool overrides)
+ if (tool.getOutputParameters().isEmpty() && !function.getOutputParameters().isEmpty()) {
+ for (OutputParameterSpec param : function.getOutputParameters()) {
+ tool.getOutputParameters().add(param);
+ }
+ }
+
+ // Derive MCP hints from function semantics, with tool-level override
+ if (function.getSemantics() != null) {
+ McpToolHintsSpec derived = deriveHints(function.getSemantics());
+ tool.setHints(mergeHints(derived, tool.getHints()));
+ }
+ }
+
+ /**
+ * Resolve a ref on a REST operation. Merges inherited fields.
+ */
+ void resolveRestOperationRef(RestServerOperationSpec op,
+ Map functionMap) {
+ AggregateFunctionSpec function = lookupFunction(op.getRef(), functionMap);
+
+ // Merge name
+ if (op.getName() == null || op.getName().isEmpty()) {
+ op.setName(function.getName());
+ }
+
+ // Merge description
+ if (op.getDescription() == null || op.getDescription().isEmpty()) {
+ op.setDescription(function.getDescription());
+ }
+
+ // Merge call
+ if (op.getCall() == null && function.getCall() != null) {
+ op.setCall(function.getCall());
+ }
+
+ // Merge with
+ if (op.getWith() == null && function.getWith() != null) {
+ op.setWith(function.getWith());
+ }
+
+ // Merge steps
+ if (op.getSteps().isEmpty() && !function.getSteps().isEmpty()) {
+ for (OperationStepSpec step : function.getSteps()) {
+ op.getSteps().add(step);
+ }
+ }
+
+ // Merge inputParameters
+ if (op.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) {
+ for (InputParameterSpec param : function.getInputParameters()) {
+ op.getInputParameters().add(param);
+ }
+ }
+
+ // Merge outputParameters
+ if (op.getOutputParameters().isEmpty() && !function.getOutputParameters().isEmpty()) {
+ for (OutputParameterSpec param : function.getOutputParameters()) {
+ op.getOutputParameters().add(param);
+ }
+ }
+ }
+
+ /**
+ * Look up a function by ref key. Fails fast on unknown or chained refs.
+ */
+ AggregateFunctionSpec lookupFunction(String ref,
+ Map functionMap) {
+ AggregateFunctionSpec function = functionMap.get(ref);
+ if (function == null) {
+ throw new IllegalArgumentException(
+ "Unknown aggregate function ref: '" + ref
+ + "'. Available refs: " + functionMap.keySet());
+ }
+ return function;
+ }
+
+ /**
+ * Derive MCP tool hints from transport-neutral semantics.
+ *
+ *
+ * Mapping:
+ *
+ * - {@code safe: true} → {@code readOnly: true, destructive: false}
+ * - {@code safe: false/null} → {@code readOnly: false}
+ * - {@code idempotent} → copied directly
+ * - {@code cacheable} → not mapped (no MCP equivalent)
+ * - {@code openWorld} → not derived (MCP-specific)
+ *
+ */
+ McpToolHintsSpec deriveHints(SemanticsSpec semantics) {
+ McpToolHintsSpec hints = new McpToolHintsSpec();
+
+ if (Boolean.TRUE.equals(semantics.getSafe())) {
+ hints.setReadOnly(true);
+ hints.setDestructive(false);
+ } else if (Boolean.FALSE.equals(semantics.getSafe())) {
+ hints.setReadOnly(false);
+ }
+
+ if (semantics.getIdempotent() != null) {
+ hints.setIdempotent(semantics.getIdempotent());
+ }
+
+ // cacheable has no MCP equivalent — not mapped
+ // openWorld is MCP-specific — not derived from semantics
+
+ return hints;
+ }
+
+ /**
+ * Merge derived hints with explicit tool-level overrides. Each non-null explicit field wins over
+ * the derived value.
+ *
+ * @param derived Hints derived from semantics (never null)
+ * @param explicit Explicit tool-level hints (may be null)
+ * @return Merged hints
+ */
+ McpToolHintsSpec mergeHints(McpToolHintsSpec derived, McpToolHintsSpec explicit) {
+ if (explicit == null) {
+ return derived;
+ }
+
+ // Each explicit non-null field overrides the derived value
+ if (explicit.getReadOnly() != null) {
+ derived.setReadOnly(explicit.getReadOnly());
+ }
+ if (explicit.getDestructive() != null) {
+ derived.setDestructive(explicit.getDestructive());
+ }
+ if (explicit.getIdempotent() != null) {
+ derived.setIdempotent(explicit.getIdempotent());
+ }
+ if (explicit.getOpenWorld() != null) {
+ derived.setOpenWorld(explicit.getOpenWorld());
+ }
+
+ return derived;
+ }
+
+}
diff --git a/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java
new file mode 100644
index 00000000..a5750103
--- /dev/null
+++ b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java
@@ -0,0 +1,119 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.spec;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.naftiko.spec.exposes.OperationStepSpec;
+import io.naftiko.spec.exposes.ServerCallSpec;
+
+/**
+ * Aggregate Function Specification Element.
+ *
+ * A reusable invocable unit within an aggregate. Adapter units reference it via
+ * ref: aggregate-namespace.function-name.
+ */
+public class AggregateFunctionSpec {
+
+ private volatile String name;
+ private volatile String description;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile SemanticsSpec semantics;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List inputParameters;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile ServerCallSpec call;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile Map with;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List steps;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List