Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions .agents/skills/naftiko-capability/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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.
27 changes: 27 additions & 0 deletions .agents/skills/naftiko-capability/references/design-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion .agents/skills/naftiko-capability/references/wrap-api-as-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
/.vscode
/target
dependency-reduced-pom.xml
/.github/node_modules
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<avro.version>1.11.4</avro.version>
<jetty.version>12.0.25</jetty.version>
<json-smart.version>2.5.2</json-smart.version>
<junit.jupiter.version>5.12.2</junit.jupiter.version>
<jacoco.version>0.8.13</jacoco.version>
</properties>
<distributionManagement>
<repository>
Expand Down Expand Up @@ -158,7 +160,7 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.2</version>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
Expand Down Expand Up @@ -208,7 +210,7 @@
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
Expand Down
56 changes: 54 additions & 2 deletions src/main/java/io/naftiko/Capability.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,20 @@
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.naftiko.engine.BindingResolver;
import io.naftiko.engine.ConsumesImportResolver;
import io.naftiko.engine.aggregates.Aggregate;
import io.naftiko.engine.aggregates.AggregateFunction;
import io.naftiko.engine.aggregates.AggregateRefResolver;
import io.naftiko.engine.exposes.OperationStepExecutor;
import io.naftiko.spec.AggregateSpec;
import io.naftiko.spec.ExecutionContext;
import io.naftiko.engine.consumes.ClientAdapter;
import io.naftiko.engine.consumes.ConsumesImportResolver;
import io.naftiko.engine.consumes.http.HttpClientAdapter;
import io.naftiko.engine.exposes.ServerAdapter;
import io.naftiko.engine.exposes.mcp.McpServerAdapter;
import io.naftiko.engine.exposes.rest.RestServerAdapter;
import io.naftiko.engine.exposes.skill.SkillServerAdapter;
import io.naftiko.engine.util.BindingResolver;
import io.naftiko.spec.NaftikoSpec;
import io.naftiko.spec.consumes.ClientSpec;
import io.naftiko.spec.consumes.HttpClientSpec;
Expand All @@ -46,6 +51,7 @@ public class Capability {
private volatile NaftikoSpec spec;
private volatile List<ServerAdapter> serverAdapters;
private volatile List<ClientAdapter> clientAdapters;
private volatile List<Aggregate> aggregates;
private volatile Map<String, Object> bindings;

public Capability(NaftikoSpec spec) throws Exception {
Expand All @@ -68,6 +74,19 @@ public Capability(NaftikoSpec spec, String capabilityDir) throws Exception {
importResolver.resolveImports(spec.getCapability().getConsumes(), capabilityDir);
}

// Resolve aggregate function refs (validate + derive MCP hints) before adapter init
AggregateRefResolver aggregateRefResolver = new AggregateRefResolver();
aggregateRefResolver.resolve(spec);

// Build runtime aggregates (must happen after imports are resolved)
this.aggregates = new CopyOnWriteArrayList<>();
if (spec.getCapability() != null && !spec.getCapability().getAggregates().isEmpty()) {
OperationStepExecutor sharedExecutor = new OperationStepExecutor(this);
for (AggregateSpec aggSpec : spec.getCapability().getAggregates()) {
this.aggregates.add(new Aggregate(aggSpec, sharedExecutor));
}
}

// Resolve bindings early for injection into adapters
BindingResolver bindingResolver = new BindingResolver();
ExecutionContext context = new ExecutionContext() {
Expand Down Expand Up @@ -123,6 +142,39 @@ public List<ServerAdapter> getServerAdapters() {
return serverAdapters;
}

public List<Aggregate> getAggregates() {
return aggregates;
}

/**
* Look up an aggregate function by ref key ({@code "namespace.functionName"}).
*
* @param ref the ref key, e.g. {@code "forecast.get-forecast"}
* @return the matching {@link AggregateFunction}
* @throws IllegalArgumentException if the ref cannot be resolved
*/
public AggregateFunction lookupFunction(String ref) {
int dot = ref.indexOf('.');
if (dot <= 0 || dot == ref.length() - 1) {
throw new IllegalArgumentException(
"Invalid aggregate function ref format: '" + ref
+ "'. Expected 'namespace.functionName'");
}
String namespace = ref.substring(0, dot);
String functionName = ref.substring(dot + 1);

for (Aggregate agg : aggregates) {
if (agg.getNamespace().equals(namespace)) {
AggregateFunction fn = agg.findFunction(functionName);
if (fn != null) {
return fn;
}
}
}
throw new IllegalArgumentException(
"Unknown aggregate function ref: '" + ref + "'");
}

/**
* Returns the map of resolved bindings. These are injected into parameter resolution contexts.
*
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/io/naftiko/engine/aggregates/Aggregate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Copyright 2025-2026 Naftiko
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package io.naftiko.engine.aggregates;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import io.naftiko.engine.exposes.OperationStepExecutor;
import io.naftiko.spec.AggregateFunctionSpec;
import io.naftiko.spec.AggregateSpec;

/**
* Runtime representation of a domain aggregate.
*
* <p>Wraps an {@link AggregateSpec} (YAML data) and owns executable
* {@link AggregateFunction} instances for each function defined in the spec.</p>
*/
public class Aggregate {

private final String namespace;
private final List<AggregateFunction> functions;

public Aggregate(AggregateSpec spec, OperationStepExecutor stepExecutor) {
this.namespace = spec.getNamespace();
this.functions = new CopyOnWriteArrayList<>();
for (AggregateFunctionSpec fnSpec : spec.getFunctions()) {
this.functions.add(new AggregateFunction(fnSpec, stepExecutor));
}
}

public String getNamespace() {
return namespace;
}

public List<AggregateFunction> getFunctions() {
return functions;
}

/**
* Find a function by name within this aggregate.
*
* @param name the function name
* @return the function, or {@code null} if not found
*/
public AggregateFunction findFunction(String name) {
for (AggregateFunction fn : functions) {
if (fn.getName().equals(name)) {
return fn;
}
}
return null;
}

}
Loading
Loading