diff --git a/src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java b/src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java
new file mode 100644
index 0000000..06eb166
--- /dev/null
+++ b/src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java
@@ -0,0 +1,162 @@
+/**
+ * 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;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import io.naftiko.spec.exposes.McpPromptMessageSpec;
+import io.naftiko.spec.exposes.McpServerPromptSpec;
+
+/**
+ * Handles MCP {@code prompts/get} requests by rendering prompt templates.
+ *
+ *
Inline prompts substitute {@code {{arg}}} placeholders in the declared message list.
+ * Argument values are treated as literal text — nested {@code {{...}}} in values are NOT
+ * re-interpolated (prevents prompt injection).
+ *
+ * File-based prompts load the file at the {@code location} URI, substitute arguments,
+ * and return the content as a single {@code user} role message.
+ */
+public class McpPromptHandler {
+
+ /** Matches {{argName}} placeholders — arg names are alphanumeric + underscore. */
+ private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{([a-zA-Z0-9_]+)\\}\\}");
+
+ private final Map promptSpecs;
+
+ public McpPromptHandler(List prompts) {
+ this.promptSpecs = new ConcurrentHashMap<>();
+ for (McpServerPromptSpec prompt : prompts) {
+ promptSpecs.put(prompt.getName(), prompt);
+ }
+ }
+
+ /**
+ * A rendered prompt message returned by {@link #render(String, Map)}.
+ */
+ public static class RenderedMessage {
+ public final String role;
+ public final String text;
+
+ public RenderedMessage(String role, String text) {
+ this.role = role;
+ this.text = text;
+ }
+ }
+
+ /**
+ * Render a prompt by name with the provided arguments.
+ *
+ * @param name the prompt name
+ * @param arguments key-value argument map (values are always treated as strings)
+ * @return ordered list of rendered messages
+ * @throws IllegalArgumentException when the prompt is unknown
+ * @throws IOException when a file-based prompt cannot be read
+ */
+ public List render(String name, Map arguments)
+ throws IOException {
+
+ McpServerPromptSpec spec = promptSpecs.get(name);
+ if (spec == null) {
+ throw new IllegalArgumentException("Unknown prompt: " + name);
+ }
+
+ Map args = toStringMap(arguments);
+
+ if (spec.isFileBased()) {
+ return renderFileBased(spec, args);
+ } else {
+ return renderInline(spec, args);
+ }
+ }
+
+ /**
+ * Return all prompt specs (for {@code prompts/list}).
+ */
+ public List listAll() {
+ return new ArrayList<>(promptSpecs.values());
+ }
+
+ // ── Inline rendering ─────────────────────────────────────────────────────────────────────────
+
+ private List renderInline(McpServerPromptSpec spec,
+ Map args) {
+ List messages = new ArrayList<>();
+ for (McpPromptMessageSpec msg : spec.getTemplate()) {
+ String rendered = substitute(msg.getContent(), args);
+ messages.add(new RenderedMessage(msg.getRole(), rendered));
+ }
+ return messages;
+ }
+
+ // ── File-based rendering ─────────────────────────────────────────────────────────────────────
+
+ private List renderFileBased(McpServerPromptSpec spec,
+ Map args) throws IOException {
+ Path file = Paths.get(URI.create(spec.getLocation()));
+ if (!Files.isRegularFile(file)) {
+ throw new IOException(
+ "Prompt file not found for '" + spec.getName() + "': " + spec.getLocation());
+ }
+ String content = Files.readString(file, StandardCharsets.UTF_8);
+ String rendered = substitute(content, args);
+ return List.of(new RenderedMessage("user", rendered));
+ }
+
+ // ── Argument substitution ────────────────────────────────────────────────────────────────────
+
+ /**
+ * Replace all {@code {{argName}}} occurrences in {@code template} with the corresponding value
+ * from {@code args}. Unrecognised placeholders are left as-is. Argument values are escaped so
+ * that any {@code {{...}}} sequences they contain are NOT treated as additional placeholders
+ * (prevents prompt injection).
+ */
+ public static String substitute(String template, Map args) {
+ if (template == null) {
+ return "";
+ }
+ Matcher m = PLACEHOLDER.matcher(template);
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) {
+ String argName = m.group(1);
+ String value = args.getOrDefault(argName, m.group(0)); // leave placeholder if missing
+ // Escape literal backslashes and dollar signs for Matcher.appendReplacement
+ m.appendReplacement(sb, Matcher.quoteReplacement(value));
+ }
+ m.appendTail(sb);
+ return sb.toString();
+ }
+
+ private static Map toStringMap(Map arguments) {
+ Map result = new ConcurrentHashMap<>();
+ if (arguments != null) {
+ for (Map.Entry entry : arguments.entrySet()) {
+ if (entry.getValue() != null) {
+ result.put(entry.getKey(), String.valueOf(entry.getValue()));
+ }
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java b/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java
index da6ab1f..dfd176e 100644
--- a/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java
+++ b/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java
@@ -13,6 +13,7 @@
*/
package io.naftiko.engine.exposes;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
@@ -22,18 +23,22 @@
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.modelcontextprotocol.spec.McpSchema;
+import io.naftiko.spec.exposes.McpPromptArgumentSpec;
+import io.naftiko.spec.exposes.McpServerPromptSpec;
+import io.naftiko.spec.exposes.McpServerResourceSpec;
/**
* Transport-agnostic MCP JSON-RPC protocol dispatcher.
*
- * Handles MCP protocol methods (initialize, tools/list, tools/call, ping)
+ * Handles MCP protocol methods (initialize, tools/list, tools/call, resources/list,
+ * resources/read, resources/templates/list, prompts/list, prompts/get, ping)
* and produces JSON-RPC response envelopes. Used by both the Streamable HTTP
* handler and the stdio handler.
*/
public class McpProtocolDispatcher {
static final String JSONRPC_VERSION = "2.0";
- static final String MCP_PROTOCOL_VERSION = "2025-03-26";
+ static final String MCP_PROTOCOL_VERSION = "2025-11-25";
private final McpServerAdapter adapter;
private final ObjectMapper mapper;
@@ -75,6 +80,21 @@ public ObjectNode dispatch(JsonNode request) {
case "tools/call":
return handleToolsCall(idNode, params);
+ case "resources/list":
+ return handleResourcesList(idNode);
+
+ case "resources/read":
+ return handleResourcesRead(idNode, params);
+
+ case "resources/templates/list":
+ return handleResourcesTemplatesList(idNode);
+
+ case "prompts/list":
+ return handlePromptsList(idNode);
+
+ case "prompts/get":
+ return handlePromptsGet(idNode, params);
+
case "ping":
return buildJsonRpcResult(idNode, mapper.createObjectNode());
@@ -95,9 +115,15 @@ private ObjectNode handleInitialize(JsonNode id) {
ObjectNode result = mapper.createObjectNode();
result.put("protocolVersion", MCP_PROTOCOL_VERSION);
- // Server capabilities — we support tools
+ // Conditionally advertise capabilities based on what is declared in the spec
ObjectNode capabilities = mapper.createObjectNode();
capabilities.putObject("tools");
+ if (!adapter.getMcpServerSpec().getResources().isEmpty()) {
+ capabilities.putObject("resources");
+ }
+ if (!adapter.getMcpServerSpec().getPrompts().isEmpty()) {
+ capabilities.putObject("prompts");
+ }
result.set("capabilities", capabilities);
// Server info
@@ -120,11 +146,17 @@ private ObjectNode handleInitialize(JsonNode id) {
private ObjectNode handleToolsList(JsonNode id) {
ObjectNode result = mapper.createObjectNode();
ArrayNode toolsArray = result.putArray("tools");
+ Map labels = adapter.getToolLabels();
for (McpSchema.Tool tool : adapter.getTools()) {
ObjectNode toolNode = mapper.createObjectNode();
toolNode.put("name", tool.name());
-
+
+ String title = labels.get(tool.name());
+ if (title != null) {
+ toolNode.put("title", title);
+ }
+
if (tool.description() != null) {
toolNode.put("description", tool.description());
}
@@ -175,6 +207,180 @@ private ObjectNode handleToolsCall(JsonNode id, JsonNode params) {
}
}
+ /**
+ * Handle resources/list request.
+ */
+ private ObjectNode handleResourcesList(JsonNode id) {
+ ObjectNode result = mapper.createObjectNode();
+ ArrayNode resourcesArray = result.putArray("resources");
+
+ for (Map entry : adapter.getResourceHandler().listAll()) {
+ ObjectNode node = mapper.createObjectNode();
+ node.put("uri", entry.get("uri"));
+ node.put("name", entry.get("name"));
+ String title = entry.get("label");
+ if (title != null) {
+ node.put("title", title);
+ }
+ String description = entry.get("description");
+ if (description != null) {
+ node.put("description", description);
+ }
+ String mimeType = entry.get("mimeType");
+ if (mimeType != null) {
+ node.put("mimeType", mimeType);
+ }
+ resourcesArray.add(node);
+ }
+
+ return buildJsonRpcResult(id, result);
+ }
+
+ /**
+ * Handle resources/read request.
+ */
+ private ObjectNode handleResourcesRead(JsonNode id, JsonNode params) {
+ if (params == null) {
+ return buildJsonRpcError(id, -32602, "Invalid params: missing params");
+ }
+ String uri = params.path("uri").asText("");
+ if (uri.isEmpty()) {
+ return buildJsonRpcError(id, -32602, "Invalid params: uri is required");
+ }
+
+ try {
+ List contents =
+ adapter.getResourceHandler().read(uri);
+ ObjectNode result = mapper.createObjectNode();
+ ArrayNode contentsArray = result.putArray("contents");
+ for (McpResourceHandler.ResourceContent c : contents) {
+ ObjectNode contentNode = mapper.createObjectNode();
+ contentNode.put("uri", c.uri);
+ if (c.mimeType != null) {
+ contentNode.put("mimeType", c.mimeType);
+ }
+ if (c.blob != null) {
+ contentNode.put("blob", c.blob);
+ } else {
+ contentNode.put("text", c.text != null ? c.text : "");
+ }
+ contentsArray.add(contentNode);
+ }
+ return buildJsonRpcResult(id, result);
+ } catch (IllegalArgumentException e) {
+ Context.getCurrentLogger().log(Level.SEVERE, "Error handling resources/read", e);
+ return buildJsonRpcError(id, -32602, "Invalid params: " + e.getMessage());
+ } catch (Exception e) {
+ Context.getCurrentLogger().log(Level.SEVERE, "Error handling resources/read", e);
+ return buildJsonRpcError(id, -32603, "Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Handle resources/templates/list request.
+ */
+ private ObjectNode handleResourcesTemplatesList(JsonNode id) {
+ ObjectNode result = mapper.createObjectNode();
+ ArrayNode templatesArray = result.putArray("resourceTemplates");
+
+ for (McpServerResourceSpec spec : adapter.getResourceHandler().listTemplates()) {
+ ObjectNode node = mapper.createObjectNode();
+ node.put("uriTemplate", spec.getUri());
+ node.put("name", spec.getName());
+ if (spec.getLabel() != null) {
+ node.put("title", spec.getLabel());
+ }
+ if (spec.getDescription() != null) {
+ node.put("description", spec.getDescription());
+ }
+ if (spec.getMimeType() != null) {
+ node.put("mimeType", spec.getMimeType());
+ }
+ templatesArray.add(node);
+ }
+
+ return buildJsonRpcResult(id, result);
+ }
+
+ /**
+ * Handle prompts/list request.
+ */
+ private ObjectNode handlePromptsList(JsonNode id) {
+ ObjectNode result = mapper.createObjectNode();
+ ArrayNode promptsArray = result.putArray("prompts");
+
+ for (McpServerPromptSpec spec : adapter.getPromptHandler().listAll()) {
+ ObjectNode promptNode = mapper.createObjectNode();
+ promptNode.put("name", spec.getName());
+ if (spec.getLabel() != null) {
+ promptNode.put("title", spec.getLabel());
+ }
+ if (spec.getDescription() != null) {
+ promptNode.put("description", spec.getDescription());
+ }
+ if (!spec.getArguments().isEmpty()) {
+ ArrayNode argsArray = promptNode.putArray("arguments");
+ for (McpPromptArgumentSpec arg : spec.getArguments()) {
+ ObjectNode argNode = mapper.createObjectNode();
+ argNode.put("name", arg.getName());
+ if (arg.getLabel() != null) {
+ argNode.put("title", arg.getLabel());
+ }
+ if (arg.getDescription() != null) {
+ argNode.put("description", arg.getDescription());
+ }
+ argNode.put("required", arg.isRequired());
+ argsArray.add(argNode);
+ }
+ }
+ promptsArray.add(promptNode);
+ }
+
+ return buildJsonRpcResult(id, result);
+ }
+
+ /**
+ * Handle prompts/get request — render a prompt with the provided arguments.
+ */
+ @SuppressWarnings("unchecked")
+ private ObjectNode handlePromptsGet(JsonNode id, JsonNode params) {
+ if (params == null) {
+ return buildJsonRpcError(id, -32602, "Invalid params: missing params");
+ }
+ String name = params.path("name").asText("");
+ if (name.isEmpty()) {
+ return buildJsonRpcError(id, -32602, "Invalid params: name is required");
+ }
+
+ try {
+ JsonNode argumentsNode = params.get("arguments");
+ Map arguments = argumentsNode != null
+ ? mapper.treeToValue(argumentsNode, Map.class)
+ : new java.util.HashMap<>();
+
+ List messages =
+ adapter.getPromptHandler().render(name, arguments);
+
+ ObjectNode result = mapper.createObjectNode();
+ ArrayNode messagesArray = result.putArray("messages");
+ for (McpPromptHandler.RenderedMessage msg : messages) {
+ ObjectNode msgNode = mapper.createObjectNode();
+ msgNode.put("role", msg.role);
+ ObjectNode contentNode = msgNode.putObject("content");
+ contentNode.put("type", "text");
+ contentNode.put("text", msg.text);
+ messagesArray.add(msgNode);
+ }
+ return buildJsonRpcResult(id, result);
+ } catch (IllegalArgumentException e) {
+ Context.getCurrentLogger().log(Level.SEVERE, "Error handling prompts/get", e);
+ return buildJsonRpcError(id, -32602, "Invalid params: " + e.getMessage());
+ } catch (Exception e) {
+ Context.getCurrentLogger().log(Level.SEVERE, "Error handling prompts/get", e);
+ return buildJsonRpcError(id, -32603, "Internal error: " + e.getMessage());
+ }
+ }
+
/**
* Build a JSON-RPC success response envelope.
*/
diff --git a/src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java b/src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java
new file mode 100644
index 0000000..acfebf0
--- /dev/null
+++ b/src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java
@@ -0,0 +1,327 @@
+/**
+ * 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;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import io.naftiko.Capability;
+import io.naftiko.spec.exposes.McpServerResourceSpec;
+
+/**
+ * Handles MCP resource reads by serving either dynamic (HTTP-backed) or static (file-backed)
+ * resources.
+ *
+ * Dynamic resources reuse {@link OperationStepExecutor} exactly like tools — the same
+ * {@code call}/{@code steps}/{@code with} orchestration model applies.
+ *
+ * Static resources read files from a {@code file:///} directory with strict path
+ * validation to prevent directory traversal.
+ */
+public class McpResourceHandler {
+
+ private static final Logger logger = Logger.getLogger(McpResourceHandler.class.getName());
+
+ /** Allowed path segment characters — no {@code ..}, no special characters. */
+ private static final Pattern SAFE_SEGMENT = Pattern.compile("^[a-zA-Z0-9._-]+$");
+
+ private final Capability capability;
+ private final List resourceSpecs;
+ private final OperationStepExecutor stepExecutor;
+
+ public McpResourceHandler(Capability capability, List resources) {
+ this.capability = capability;
+ this.resourceSpecs = new ArrayList<>(resources);
+ this.stepExecutor = new OperationStepExecutor(capability);
+ }
+
+ /**
+ * A single resource content item returned by {@link #read}.
+ */
+ public static class ResourceContent {
+ public final String uri;
+ public final String mimeType;
+ /** UTF-8 text content, or {@code null} when binary. */
+ public final String text;
+ /** Base64-encoded binary content, or {@code null} when text. */
+ public final String blob;
+
+ private ResourceContent(String uri, String mimeType, String text, String blob) {
+ this.uri = uri;
+ this.mimeType = mimeType;
+ this.text = text;
+ this.blob = blob;
+ }
+
+ public static ResourceContent text(String uri, String mimeType, String text) {
+ return new ResourceContent(uri, mimeType, text, null);
+ }
+ }
+
+ /**
+ * Read the resource identified by {@code uri}. Resolves URI template parameters from the URI
+ * when the spec uses {@code {param}} placeholders.
+ *
+ * @param uri the concrete resource URI requested by the agent
+ * @return list of content items (typically one)
+ * @throws IOException when reading a static file fails
+ * @throws IllegalArgumentException when no matching resource is found
+ */
+ public List read(String uri) throws Exception {
+ for (McpServerResourceSpec spec : resourceSpecs) {
+ if (spec.isStatic()) {
+ // Static resources: exact URI or prefix match for file listings
+ if (uriMatchesStatic(spec, uri)) {
+ return readStatic(spec, uri);
+ }
+ } else {
+ // Dynamic resources: exact or template match
+ Map templateParams = matchTemplate(spec.getUri(), uri);
+ if (templateParams != null) {
+ return readDynamic(spec, uri, templateParams);
+ }
+ }
+ }
+ throw new IllegalArgumentException("Unknown resource URI: " + uri);
+ }
+
+ /**
+ * Enumerate all concrete resource descriptors, expanding static location directories into
+ * per-file entries.
+ *
+ * @return list of {uri, spec} pairs for resources/list
+ */
+ public List> listAll() {
+ List> result = new ArrayList<>();
+ for (McpServerResourceSpec spec : resourceSpecs) {
+ if (spec.isStatic()) {
+ result.addAll(listStaticFiles(spec));
+ } else if (!spec.isTemplate()) {
+ Map entry = new HashMap<>();
+ entry.put("uri", spec.getUri());
+ entry.put("name", spec.getName());
+ entry.put("label", spec.getLabel() != null ? spec.getLabel() : spec.getName());
+ entry.put("description", spec.getDescription());
+ if (spec.getMimeType() != null) {
+ entry.put("mimeType", spec.getMimeType());
+ }
+ result.add(entry);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Return all resource template descriptors (specs whose URIs contain {@code {param}}).
+ */
+ public List listTemplates() {
+ List templates = new ArrayList<>();
+ for (McpServerResourceSpec spec : resourceSpecs) {
+ if (spec.isTemplate()) {
+ templates.add(spec);
+ }
+ }
+ return templates;
+ }
+
+ // ── Static resource helpers ──────────────────────────────────────────────────────────────────
+
+ private boolean uriMatchesStatic(McpServerResourceSpec spec, String requestedUri) {
+ String baseUri = spec.getUri();
+ return requestedUri.equals(baseUri) || requestedUri.startsWith(baseUri + "/");
+ }
+
+ private List readStatic(McpServerResourceSpec spec, String requestedUri)
+ throws IOException {
+ Path baseDir = locationToPath(spec.getLocation());
+
+ String relative = requestedUri.equals(spec.getUri()) ? ""
+ : requestedUri.substring(spec.getUri().length() + 1); // strip leading /
+
+ Path target;
+ if (relative.isEmpty()) {
+ // URI points directly at the base directory — look for an index file or error
+ target = baseDir;
+ } else {
+ // Validate each path segment to prevent directory traversal
+ String[] segments = relative.split("/");
+ for (String segment : segments) {
+ if (!SAFE_SEGMENT.matcher(segment).matches()) {
+ throw new IllegalArgumentException(
+ "Invalid path segment in resource URI: " + segment);
+ }
+ }
+ target = baseDir.resolve(relative).normalize();
+ }
+
+ // Containment check after resolving symlinks
+ Path resolvedBase = baseDir.toRealPath();
+ Path resolvedTarget = target.toRealPath();
+ if (!resolvedTarget.startsWith(resolvedBase)) {
+ throw new IllegalArgumentException(
+ "Path traversal detected for resource URI: " + requestedUri);
+ }
+
+ if (Files.isDirectory(resolvedTarget)) {
+ throw new IllegalArgumentException(
+ "Resource URI resolves to a directory, not a file: " + requestedUri);
+ }
+
+ String mimeType = spec.getMimeType() != null ? spec.getMimeType()
+ : probeMimeType(resolvedTarget);
+ String text = Files.readString(resolvedTarget, StandardCharsets.UTF_8);
+ return List.of(ResourceContent.text(requestedUri, mimeType, text));
+ }
+
+ private List> listStaticFiles(McpServerResourceSpec spec) {
+ List> entries = new ArrayList<>();
+ try {
+ Path baseDir = locationToPath(spec.getLocation()).toRealPath();
+ Files.walk(baseDir)
+ .filter(Files::isRegularFile)
+ .sorted()
+ .forEach(file -> {
+ String relative = baseDir.relativize(file).toString().replace('\\', '/');
+ String fileUri = spec.getUri() + "/" + relative;
+ String mimeType = spec.getMimeType() != null ? spec.getMimeType()
+ : probeMimeType(file);
+ Map entry = new HashMap<>();
+ entry.put("uri", fileUri);
+ entry.put("name", spec.getName());
+ entry.put("label", spec.getLabel() != null ? spec.getLabel() : spec.getName());
+ entry.put("description", spec.getDescription());
+ if (mimeType != null) {
+ entry.put("mimeType", mimeType);
+ }
+ entries.add(entry);
+ });
+ } catch (IOException e) {
+ logger.warning("Cannot list static resource directory for '" + spec.getName()
+ + "': " + e.getMessage());
+ }
+ return entries;
+ }
+
+ private static Path locationToPath(String location) {
+ // location is a file:/// URI
+ return Paths.get(URI.create(location));
+ }
+
+ private static String probeMimeType(Path path) {
+ try {
+ String probed = Files.probeContentType(path);
+ if (probed != null) {
+ return probed;
+ }
+ } catch (IOException ignored) {
+ }
+ String name = path.getFileName().toString().toLowerCase();
+ if (name.endsWith(".md")) return "text/markdown";
+ if (name.endsWith(".json")) return "application/json";
+ if (name.endsWith(".yaml") || name.endsWith(".yml")) return "application/yaml";
+ if (name.endsWith(".txt")) return "text/plain";
+ if (name.endsWith(".xml")) return "application/xml";
+ return "application/octet-stream";
+ }
+
+ // ── Dynamic resource helpers ─────────────────────────────────────────────────────────────────
+
+ private List readDynamic(McpServerResourceSpec spec, String uri,
+ Map templateParams) throws Exception {
+
+ Map parameters = new HashMap<>(templateParams);
+ if (spec.getWith() != null) {
+ parameters.putAll(spec.getWith());
+ }
+
+ OperationStepExecutor.HandlingContext found =
+ stepExecutor.execute(spec.getCall(), spec.getSteps(), parameters,
+ "Resource '" + spec.getName() + "'");
+
+ String text = extractContent(spec, found);
+ String mimeType = spec.getMimeType() != null ? spec.getMimeType() : "application/json";
+ return List.of(ResourceContent.text(uri, mimeType, text));
+ }
+
+ private String extractContent(McpServerResourceSpec spec,
+ OperationStepExecutor.HandlingContext found) throws IOException {
+
+ if (found == null || found.clientResponse == null
+ || found.clientResponse.getEntity() == null) {
+ return "";
+ }
+
+ String responseText = found.clientResponse.getEntity().getText();
+ if (responseText == null) {
+ return "";
+ }
+
+ String mapped = stepExecutor.applyOutputMappings(responseText, spec.getOutputParameters());
+ return mapped != null ? mapped : responseText;
+ }
+
+ /**
+ * Match a concrete URI against a (possibly templated) spec URI.
+ * Returns a map of extracted template parameters, or {@code null} if no match.
+ */
+ public static Map matchTemplate(String specUri, String concreteUri) {
+ if (specUri == null || concreteUri == null) {
+ return null;
+ }
+ if (!specUri.contains("{")) {
+ // Exact match required for non-template URIs
+ return specUri.equals(concreteUri) ? new HashMap<>() : null;
+ }
+
+ // Build a regex from the template URI
+ StringBuilder regex = new StringBuilder("^");
+ Pattern varPattern = Pattern.compile("\\{([a-zA-Z0-9_]+)\\}");
+ Matcher varMatcher = varPattern.matcher(specUri);
+ List varNames = new ArrayList<>();
+ int last = 0;
+ while (varMatcher.find()) {
+ regex.append(Pattern.quote(specUri.substring(last, varMatcher.start())));
+ regex.append("([^/]+)");
+ varNames.add(varMatcher.group(1));
+ last = varMatcher.end();
+ }
+ regex.append(Pattern.quote(specUri.substring(last)));
+ regex.append("$");
+
+ Matcher m = Pattern.compile(regex.toString()).matcher(concreteUri);
+ if (!m.matches()) {
+ return null;
+ }
+ Map params = new ConcurrentHashMap<>();
+ for (int i = 0; i < varNames.size(); i++) {
+ params.put(varNames.get(i), m.group(i + 1));
+ }
+ return params;
+ }
+
+ public Capability getCapability() {
+ return capability;
+ }
+}
diff --git a/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java
index 903f525..2141e77 100644
--- a/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java
+++ b/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java
@@ -51,21 +51,34 @@ public class McpServerAdapter extends ServerAdapter {
private final McpToolHandler toolHandler;
private final List tools;
+ private final Map toolLabels;
+ private final McpResourceHandler resourceHandler;
+ private final McpPromptHandler promptHandler;
public McpServerAdapter(Capability capability, McpServerSpec serverSpec) {
super(capability, serverSpec);
// Build MCP Tool definitions from the spec
this.tools = new ArrayList<>();
+ this.toolLabels = new HashMap<>();
Context.getCurrentLogger().log(Level.INFO, "Building MCP Tool definitions from the spec");
for (McpServerToolSpec toolSpec : serverSpec.getTools()) {
this.tools.add(buildMcpTool(toolSpec));
+ if (toolSpec.getLabel() != null) {
+ this.toolLabels.put(toolSpec.getName(), toolSpec.getLabel());
+ }
}
// Create the tool handler (transport-agnostic)
this.toolHandler = new McpToolHandler(capability, serverSpec.getTools());
+ // Create the resource handler (transport-agnostic)
+ this.resourceHandler = new McpResourceHandler(capability, serverSpec.getResources());
+
+ // Create the prompt handler (transport-agnostic)
+ this.promptHandler = new McpPromptHandler(serverSpec.getPrompts());
+
if (serverSpec.isStdio()) {
initStdioTransport();
} else {
@@ -144,10 +157,22 @@ public McpToolHandler getToolHandler() {
return toolHandler;
}
+ public McpResourceHandler getResourceHandler() {
+ return resourceHandler;
+ }
+
+ public McpPromptHandler getPromptHandler() {
+ return promptHandler;
+ }
+
public List getTools() {
return tools;
}
+ public Map getToolLabels() {
+ return toolLabels;
+ }
+
@Override
public void start() throws Exception {
if (getMcpServerSpec().isStdio()) {
diff --git a/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java b/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java
index eaffd21..30dfc22 100644
--- a/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java
+++ b/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java
@@ -19,13 +19,8 @@
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.NullNode;
import io.modelcontextprotocol.spec.McpSchema;
import io.naftiko.Capability;
-import io.naftiko.engine.Resolver;
-import io.naftiko.spec.OutputParameterSpec;
import io.naftiko.spec.exposes.McpServerToolSpec;
/**
@@ -40,13 +35,11 @@ public class McpToolHandler {
private static final Logger logger = Logger.getLogger(McpToolHandler.class.getName());
private final Capability capability;
private final Map toolSpecs;
- private final ObjectMapper mapper;
private final OperationStepExecutor stepExecutor;
public McpToolHandler(Capability capability, List tools) {
this.capability = capability;
this.toolSpecs = new ConcurrentHashMap<>();
- this.mapper = new ObjectMapper();
this.stepExecutor = new OperationStepExecutor(capability);
for (McpServerToolSpec tool : tools) {
@@ -78,35 +71,18 @@ public McpSchema.CallToolResult handleToolCall(String toolName, MapWhen {@code call} is non-null the matching client adapter is located, the request is
+ * built and {@link HandlingContext#handle()} is invoked. When {@code steps} is non-empty the
+ * full step-orchestration path runs instead. Throws {@link IllegalArgumentException} when the
+ * call reference cannot be resolved or neither {@code call} nor {@code steps} is defined.
+ *
+ * @param call the simple call spec, or {@code null}
+ * @param steps the step list, or {@code null}/empty
+ * @param parameters resolved parameters available for template substitution
+ * @param entityLabel human-readable label used in error messages (e.g. {@code "Tool 'my-tool'"})
+ * @return the resulting {@link HandlingContext}
+ * @throws IllegalArgumentException when the call reference is invalid or neither mode is
+ * defined
+ * @throws Exception when the underlying HTTP request fails
+ */
+ public HandlingContext execute(ServerCallSpec call, List steps,
+ Map parameters, String entityLabel) throws Exception {
+ if (call != null) {
+ HandlingContext found = findClientRequestFor(call, parameters);
+ if (found == null) {
+ throw new IllegalArgumentException(
+ "Invalid call for " + entityLabel + ": " + call.getOperation());
+ }
+ found.handle();
+ return found;
+ } else if (steps != null && !steps.isEmpty()) {
+ return executeSteps(steps, parameters).lastContext;
+ } else {
+ throw new IllegalArgumentException(
+ entityLabel + " has neither call nor steps defined");
+ }
+ }
+
+ /**
+ * Apply output parameter mappings to a JSON response string.
+ *
+ * Parses {@code responseText} as JSON and evaluates each {@link OutputParameterSpec} in
+ * order, returning the first non-null mapped value serialised back to JSON. Returns
+ * {@code null} when no mapping matches (callers should fall back to the raw response).
+ *
+ * @param responseText the raw HTTP response body
+ * @param outputParameters the list of output parameter specs to try
+ * @return the first mapped JSON string, or {@code null} if none matched
+ */
+ public String applyOutputMappings(String responseText,
+ List outputParameters) throws IOException {
+ if (responseText == null || responseText.isEmpty()) {
+ return null;
+ }
+ if (outputParameters == null || outputParameters.isEmpty()) {
+ return null;
+ }
+ JsonNode root = mapper.readTree(responseText);
+ for (OutputParameterSpec outputParam : outputParameters) {
+ JsonNode mapped = Resolver.resolveOutputMappings(outputParam, root, mapper);
+ if (mapped != null && !(mapped instanceof NullNode)) {
+ return mapper.writeValueAsString(mapped);
+ }
+ }
+ return null;
+ }
+
/**
* Internal context for managing an HTTP client request-response pair.
*/
diff --git a/src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java b/src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java
new file mode 100644
index 0000000..7cc2ce2
--- /dev/null
+++ b/src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.spec.exposes;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+/**
+ * An argument for an MCP prompt template.
+ *
+ * Arguments are always strings per the MCP specification. They are substituted into
+ * prompt templates via {@code {{name}}} placeholders.
+ */
+public class McpPromptArgumentSpec {
+
+ private volatile String name;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String label;
+
+ private volatile String description;
+
+ /** Defaults to {@code true} — arguments are required unless explicitly set to false. */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile Boolean required;
+
+ public McpPromptArgumentSpec() {}
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public Boolean getRequired() {
+ return required;
+ }
+
+ public void setRequired(Boolean required) {
+ this.required = required;
+ }
+
+ /**
+ * Returns {@code true} unless {@code required} is explicitly set to {@code false}.
+ */
+ public boolean isRequired() {
+ return required == null || required;
+ }
+}
diff --git a/src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java b/src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java
new file mode 100644
index 0000000..e78ce71
--- /dev/null
+++ b/src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.spec.exposes;
+
+/**
+ * A single message in an inline MCP prompt template.
+ *
+ * The {@code role} must be {@code "user"} or {@code "assistant"}.
+ * The {@code content} may contain {@code {{arg}}} placeholders that are substituted
+ * when the prompt is rendered via {@code prompts/get}.
+ */
+public class McpPromptMessageSpec {
+
+ private volatile String role;
+ private volatile String content;
+
+ public McpPromptMessageSpec() {}
+
+ public McpPromptMessageSpec(String role, String content) {
+ this.role = role;
+ this.content = content;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(String role) {
+ this.role = role;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+}
diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java
new file mode 100644
index 0000000..dc14974
--- /dev/null
+++ b/src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java
@@ -0,0 +1,101 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.spec.exposes;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+/**
+ * MCP Prompt Specification Element.
+ *
+ * Defines an MCP prompt template with typed arguments that agents can discover and render.
+ * Two source types are supported:
+ *
+ * Inline ({@code template}): prompt messages declared directly in YAML with
+ * {@code {{arg}}} placeholders.
+ * File-based ({@code location}): prompt content loaded from a {@code file:///} URI;
+ * the file content becomes a single {@code user} role message.
+ *
+ */
+public class McpServerPromptSpec {
+
+ private volatile String name;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String label;
+
+ private volatile String description;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List arguments;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List template;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String location;
+
+ public McpServerPromptSpec() {
+ this.arguments = new CopyOnWriteArrayList<>();
+ this.template = new CopyOnWriteArrayList<>();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public List getArguments() {
+ return arguments;
+ }
+
+ public List getTemplate() {
+ return template;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public void setLocation(String location) {
+ this.location = location;
+ }
+
+ /**
+ * Returns {@code true} when this prompt is rendered from a local file.
+ */
+ public boolean isFileBased() {
+ return location != null;
+ }
+}
diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java
new file mode 100644
index 0000000..e8b9d25
--- /dev/null
+++ b/src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java
@@ -0,0 +1,153 @@
+/**
+ * Copyright 2025-2026 Naftiko
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package io.naftiko.spec.exposes;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ConcurrentHashMap;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.naftiko.spec.OutputParameterSpec;
+
+/**
+ * MCP Resource Specification Element.
+ *
+ * Defines an MCP resource that exposes data agents can read. Two source types are supported:
+ *
+ * Dynamic ({@code call}/{@code steps}): backed by consumed HTTP operations — same
+ * orchestration model as tools.
+ * Static ({@code location}): served from local files identified by a
+ * {@code file:///} URI.
+ *
+ */
+public class McpServerResourceSpec {
+
+ private volatile String name;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String label;
+
+ private volatile String uri;
+
+ private volatile String description;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String mimeType;
+
+ @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 outputParameters;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String location;
+
+ public McpServerResourceSpec() {
+ this.steps = new CopyOnWriteArrayList<>();
+ this.outputParameters = new CopyOnWriteArrayList<>();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public String getUri() {
+ return uri;
+ }
+
+ public void setUri(String uri) {
+ this.uri = uri;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public void setMimeType(String mimeType) {
+ this.mimeType = mimeType;
+ }
+
+ 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 getOutputParameters() {
+ return outputParameters;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public void setLocation(String location) {
+ this.location = location;
+ }
+
+ /**
+ * Returns {@code true} when this resource is served from a local file directory.
+ */
+ public boolean isStatic() {
+ return location != null;
+ }
+
+ /**
+ * Returns {@code true} when the URI contains {@code {param}} placeholders (resource template).
+ */
+ public boolean isTemplate() {
+ return uri != null && uri.contains("{") && uri.contains("}");
+ }
+}
diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java
index 23d4720..5e9761a 100644
--- a/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java
+++ b/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java
@@ -42,6 +42,12 @@ public class McpServerSpec extends ServerSpec {
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private final List tools;
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List resources;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final List prompts;
+
public McpServerSpec() {
this(null, 0, null, null);
}
@@ -51,6 +57,8 @@ public McpServerSpec(String address, int port, String namespace, String descript
this.namespace = namespace;
this.description = description;
this.tools = new CopyOnWriteArrayList<>();
+ this.resources = new CopyOnWriteArrayList<>();
+ this.prompts = new CopyOnWriteArrayList<>();
}
/**
@@ -88,4 +96,12 @@ public List getTools() {
return tools;
}
+ public List getResources() {
+ return resources;
+ }
+
+ public List getPrompts() {
+ return prompts;
+ }
+
}
diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java
index 1a1625d..507ac35 100644
--- a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java
+++ b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java
@@ -31,6 +31,9 @@ public class McpServerToolSpec {
private volatile String name;
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private volatile String label;
+
private volatile String description;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@@ -49,11 +52,12 @@ public class McpServerToolSpec {
private final List outputParameters;
public McpServerToolSpec() {
- this(null, null);
+ this(null, null, null);
}
- public McpServerToolSpec(String name, String description) {
+ public McpServerToolSpec(String name, String label, String description) {
this.name = name;
+ this.label = label;
this.description = description;
this.inputParameters = new CopyOnWriteArrayList<>();
this.steps = new CopyOnWriteArrayList<>();
@@ -68,6 +72,14 @@ public void setName(String name) {
this.name = name;
}
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
public String getDescription() {
return description;
}
diff --git a/src/main/resources/schemas/capability-schema.json b/src/main/resources/schemas/capability-schema.json
index ed5554e..befa449 100644
--- a/src/main/resources/schemas/capability-schema.json
+++ b/src/main/resources/schemas/capability-schema.json
@@ -746,7 +746,7 @@
},
"ExposesMcp": {
"type": "object",
- "description": "MCP Server exposition configuration. Exposes tools over MCP transport (Streamable HTTP or stdio).",
+ "description": "MCP Server exposition configuration. Exposes tools, resources and prompts over MCP transport (Streamable HTTP or stdio).",
"properties": {
"type": {
"type": "string",
@@ -784,6 +784,22 @@
"$ref": "#/$defs/McpTool"
},
"minItems": 1
+ },
+ "resources": {
+ "type": "array",
+ "description": "List of MCP resources exposed by this server. Resources provide data that agents can read.",
+ "items": {
+ "$ref": "#/$defs/McpResource"
+ },
+ "minItems": 1
+ },
+ "prompts": {
+ "type": "array",
+ "description": "List of MCP prompt templates exposed by this server. Prompts are reusable templates with typed arguments.",
+ "items": {
+ "$ref": "#/$defs/McpPrompt"
+ },
+ "minItems": 1
}
},
"required": [
@@ -828,6 +844,10 @@
"$ref": "#/$defs/IdentifierKebab",
"description": "Technical name for the tool. Used as the MCP tool name."
},
+ "label": {
+ "type": "string",
+ "description": "Human-readable display name of the tool. Mapped to MCP 'title' in tools/list responses."
+ },
"description": {
"type": "string",
"description": "A meaningful description of the tool. Used for agent discovery. Remember, in a world of agents, context is king."
@@ -939,6 +959,210 @@
],
"additionalProperties": false
},
+ "McpResource": {
+ "type": "object",
+ "description": "An MCP resource definition. Exposes data that agents can read. Either dynamic (backed by consumed HTTP operations via call/steps) or static (served from local files via location).",
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/IdentifierExtended",
+ "description": "Technical name for the resource. Used as identifier in MCP resource listings."
+ },
+ "label": {
+ "type": "string",
+ "description": "Human-readable display name of the resource. Mapped to MCP 'title' in protocol responses."
+ },
+ "uri": {
+ "type": "string",
+ "description": "The URI that identifies this resource in MCP. Can use any scheme (e.g. config://, docs://, data://). For resource templates, use {param} placeholders."
+ },
+ "description": {
+ "type": "string",
+ "description": "A meaningful description of the resource. Used for agent discovery. In a world of agents, context is king."
+ },
+ "mimeType": {
+ "type": "string",
+ "description": "MIME type of the resource content per RFC 6838 (e.g. application/json, text/markdown, text/plain).",
+ "pattern": "^[a-zA-Z0-9!#$&^_.+-]+\\/[a-zA-Z0-9!#$&^_.+-]+(?:\\s*;\\s*[a-zA-Z0-9!#$&^_.+-]+=(?:[a-zA-Z0-9!#$&^_.+-]+|\"[^\"]*\"))*$"
+ },
+ "call": {
+ "type": "string",
+ "description": "For dynamic resources: 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 resource content.",
+ "items": {
+ "$ref": "#/$defs/StepOutputMapping"
+ }
+ },
+ "outputParameters": {
+ "type": "array"
+ },
+ "location": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^file:///",
+ "description": "For static resources: file:/// URI pointing to a directory whose files are served as MCP resources."
+ }
+ },
+ "required": [
+ "name",
+ "label",
+ "uri",
+ "description"
+ ],
+ "oneOf": [
+ {
+ "required": ["call"],
+ "type": "object",
+ "properties": {
+ "outputParameters": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/MappedOutputParameter"
+ }
+ }
+ },
+ "not": { "required": ["location"] }
+ },
+ {
+ "required": ["steps"],
+ "type": "object",
+ "properties": {
+ "mappings": true,
+ "outputParameters": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/OrchestratedOutputParameter"
+ }
+ }
+ },
+ "not": { "required": ["location"] }
+ },
+ {
+ "required": ["location"],
+ "not": {
+ "anyOf": [
+ { "required": ["call"] },
+ { "required": ["steps"] }
+ ]
+ }
+ }
+ ],
+ "additionalProperties": false
+ },
+ "McpPrompt": {
+ "type": "object",
+ "description": "An MCP prompt template definition. Prompts are reusable templates with typed arguments that agents can discover and render.",
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/IdentifierExtended",
+ "description": "Technical name for the prompt. Used as identifier in MCP prompt listings."
+ },
+ "label": {
+ "type": "string",
+ "description": "Human-readable display name of the prompt. Mapped to MCP 'title' in protocol responses."
+ },
+ "description": {
+ "type": "string",
+ "description": "A meaningful description of the prompt and when to use it. Used for agent discovery."
+ },
+ "arguments": {
+ "type": "array",
+ "description": "Typed arguments for this prompt template. Arguments are substituted into the template via {{arg}} placeholders.",
+ "items": {
+ "$ref": "#/$defs/McpPromptArgument"
+ },
+ "minItems": 1
+ },
+ "template": {
+ "type": "array",
+ "description": "Inline prompt template as an array of messages. Each message has a role and content with {{arg}} placeholders.",
+ "items": {
+ "$ref": "#/$defs/McpPromptMessage"
+ },
+ "minItems": 1
+ },
+ "location": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^file:///",
+ "description": "File-based prompt: file:/// URI pointing to a file containing the prompt template with {{arg}} placeholders. Content becomes a single 'user' message."
+ }
+ },
+ "required": [
+ "name",
+ "label",
+ "description"
+ ],
+ "oneOf": [
+ {
+ "required": ["template"],
+ "not": { "required": ["location"] }
+ },
+ {
+ "required": ["location"],
+ "not": { "required": ["template"] }
+ }
+ ],
+ "additionalProperties": false
+ },
+ "McpPromptArgument": {
+ "type": "object",
+ "description": "An argument for an MCP prompt template. Arguments are always strings per MCP spec.",
+ "properties": {
+ "name": {
+ "$ref": "#/$defs/IdentifierExtended",
+ "description": "Argument name. Becomes a {{name}} placeholder in the template."
+ },
+ "label": {
+ "type": "string",
+ "description": "The display name of the argument. Used for agent discovery."
+ },
+ "description": {
+ "type": "string",
+ "description": "A meaningful description of the argument. Used for agent discovery."
+ },
+ "required": {
+ "type": "boolean",
+ "description": "Whether the argument is required. Defaults to true.",
+ "default": true
+ }
+ },
+ "required": [
+ "name",
+ "description"
+ ],
+ "additionalProperties": false
+ },
+ "McpPromptMessage": {
+ "type": "object",
+ "description": "A message in an inline MCP prompt template. Supports {{arg}} placeholders for argument substitution.",
+ "properties": {
+ "role": {
+ "type": "string",
+ "enum": ["user", "assistant"],
+ "description": "The role of the message sender."
+ },
+ "content": {
+ "type": "string",
+ "description": "The message content. Supports {{arg}} placeholders for argument substitution."
+ }
+ },
+ "required": ["role", "content"],
+ "additionalProperties": false
+ },
"ExposedResource": {
"type": "object",
"description": "An exposed resource",
diff --git a/src/main/resources/specs/naftiko-specification-v0.5.md b/src/main/resources/specs/naftiko-specification-v0.5.md
new file mode 100644
index 0000000..1362a4e
--- /dev/null
+++ b/src/main/resources/specs/naftiko-specification-v0.5.md
@@ -0,0 +1,2321 @@
+# Naftiko Specification
+
+Version: 0.5
+Created by: Thomas Eskenazi
+Category: Sepcification
+Last updated time: March 5, 2026 12:40 PM
+Reviewers: Kin Lane, Jerome Louvel, Jérémie Tarnaud, Antoine Buhl
+Status: Draft
+
+# Naftiko Specification v0.5
+
+**Version 0.5**
+
+**Publication Date:** March 2026
+
+---
+
+- **Table of Contents**
+
+## 1. Introduction
+
+The Naftiko Specification defines a standard, language-agnostic interface for describing modular, composable capabilities. In short, a **capability** is a functional unit that consumes external APIs (sources) and exposes adapters that allow other systems to interact with it.
+
+A Naftiko capability focuses on declaring the **integration intent** — what a system needs to consume and what it exposes — rather than implementation details. This higher-level abstraction makes capabilities naturally suitable for AI-driven discovery, orchestration and integration use cases, and beyond. When properly defined, a capability can be discovered, orchestrated, validated and executed with minimal implementation logic. The specification enables description of:
+
+- **Consumed sources**: External APIs or services that the capability uses
+- **Exposed adapters**: Server interfaces that the capability provides (HTTP, REST, etc.)
+- **Orchestration**: How calls to consumed sources are combined and mapped to realize exposed functions
+- **External references**: Variables and resources resolved from external sources
+
+### 1.1 Schema Access
+
+The JSON Schema for the Naftiko Specification is available in two forms:
+
+- **Raw file** — The schema source file is hosted on GitHub: [capability-schema.json](https://github.com/naftiko/framework/blob/main/src/main/resources/schemas/capability-schema.json)
+- **Interactive viewer** — A human-friendly viewer is available at: [Schema Viewer](https://naftiko.github.io/schema-viewer/)
+
+### 1.2 Core Objects
+
+**Capability**: The central object that defines a modular functional unit with clear input/output contracts.
+
+**Consumes**: External sources (APIs, services) that the capability uses to realize its operations.
+
+**Exposes**: Server adapters that provide access to the capability's operations.
+
+**Resources**: API endpoints that group related operations.
+
+**Operations**: Individual HTTP operations (GET, POST, etc.) that can be performed on resources.
+
+**Namespace**: A unique identifier for consumed sources, used for routing and mapping with the expose layer.
+
+**MCP Server**: An exposition adapter that exposes capability operations as MCP tools, enabling AI agent integration via Streamable HTTP or stdio transport.
+
+**ExternalRef**: A declaration of an external reference providing variables to the capability. Two variants: file-resolved (for development) and runtime-resolved (for production). Variables are explicitly declared via a `keys` map.
+
+### 1.3 Related Specifications.
+
+
+💡
+
+**Acknowledgments** — The Naftiko Specification is inspired by and builds upon foundational work from [OpenAPI](https://www.openapis.org/), [Arazzo](https://spec.openapis.org/arazzo/latest.html), and [OpenCollections](https://opencollections.io/). We gratefully credit these initiatives and their communities for the patterns and conventions that informed this specification.
+
+
+
+Three specifications that work better together.
+
+| | **OpenAPI** | **Arazzo** | **OpenCollections** | **Naftiko** |
+| --- | --- | --- | --- | --- |
+| **Focus** | Defines *what* your API is — the contract, the schema, the structure. | Defines *how* API calls are sequenced — the workflows between endpoints. | Defines *how* to use your API — the scenarios, the runnable collections. | Defines *what* a capability consumes and exposes — the integration intent. |
+| **Scope** | Single API surface | Workflows across one or more APIs | Runnable collections of API calls | Modular capability spanning multiple APIs |
+| **Key strengths** | ✓ Endpoints & HTTP methods, ✓ Request/response schemas, ✓ Authentication requirements, ✓ Data types & validation, ✓ SDK & docs generation | ✓ Multi-step sequences, ✓ Step dependencies & data flow, ✓ Success/failure criteria, ✓ Reusable workflow definitions | ✓ Runnable, shareable collections, ✓ Pre-request scripts & tests, ✓ Environment variables, ✓ Living, executable docs | ✓ Consume/expose duality, ✓ Namespace-based routing, ✓ Orchestration & forwarding, ✓ AI-driven discovery, ✓ Composable capabilities |
+| **Analogy** | The *parts list* and dimensions | The *assembly sequence* between parts | The *step-by-step assembly guide* you can run | The *product blueprint* — what goes in, what comes out |
+| **Best used when you need to…** | Define & document an API contract, generate SDKs, validate payloads | Describe multi-step API workflows with dependencies | Share runnable API examples, test workflows, onboard developers | Declare a composable capability that consumes sources and exposes unified interfaces |
+
+**OpenAPI** tells you the shape of the door. **Arazzo** describes the sequence of doors to walk through. **OpenCollections** lets you actually walk through them. **Naftiko** combines the features of those 3 specs into a single, coherent spec, reducing complexity and offering consistent tooling out of the box
+
+---
+
+## 2. Format
+
+Naftiko specifications can be represented in YAML format, complying with the provided Naftiko schema which is made available in both JSON Schema and [JSON Structure](https://json-structure.org/) formats.
+
+All field names in the specification are **case-sensitive**.
+
+Naftiko Objects expose two types of fields:
+
+- **Fixed fields**: which have a declared name
+- **Patterned fields**: which have a declared pattern for the field name
+
+---
+
+## 3. Objects and Fields
+
+### 3.1 Naftiko Object
+
+This is the root object of the Naftiko document.
+
+#### 3.1.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **naftiko** | `string` | **REQUIRED**. Version of the Naftiko schema. MUST be `"0.5"` for this version. |
+| **info** | `Info` | *Recommended*. Metadata about the capability. |
+| **capability** | `Capability` | **REQUIRED**. Technical configuration of the capability including sources and adapters. |
+| **externalRefs** | `ExternalRef[]` | List of external references for variable injection. Each entry declares injected variables via a `keys` map. |
+
+#### 3.1.2 Rules
+
+- The `naftiko` field MUST be present and MUST have the value `"0.5"` for documents conforming to this version of the specification.
+- The `capability` object MUST be present. The `info` object is recommended.
+- The `externalRefs` field is OPTIONAL. When present, it MUST contain at least one entry.
+- No additional properties are allowed at the root level.
+
+---
+
+### 3.2 Info Object
+
+Provides metadata about the capability.
+
+#### 3.2.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **label** | `string` | **REQUIRED**. The display name of the capability. |
+| **description** | `string` | *Recommended*. A description of the capability. The more meaningful it is, the easier for agent discovery. |
+| **tags** | `string[]` | List of tags to help categorize the capability for discovery and filtering. |
+| **created** | `string` | Date the capability was created (format: `YYYY-MM-DD`). |
+| **modified** | `string` | Date the capability was last modified (format: `YYYY-MM-DD`). |
+| **stakeholders** | `Person[]` | List of stakeholders related to this capability (for discovery and filtering). |
+
+#### 3.2.2 Rules
+
+- The `label` field is mandatory. The `description` field is recommended to improve agent discovery.
+- No additional properties are allowed.
+
+#### 3.2.3 Info Object Example
+
+```yaml
+info:
+ label: Notion Page Creator
+ description: Creates and manages Notion pages with rich content formatting
+ tags:
+ - notion
+ - automation
+ created: "2026-02-17"
+ modified: "2026-02-17"
+ stakeholders:
+ - role: owner
+ fullName: "Jane Doe"
+ email: "jane.doe@example.
+```
+
+---
+
+### 3.3 Person Object
+
+Describes a person related to the capability.
+
+#### 3.3.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **role** | `string` | **REQUIRED**. The role of the person in relation to the capability. E.g. owner, editor, viewer. |
+| **fullName** | `string` | **REQUIRED**. The full name of the person. |
+| **email** | `string` | The email address of the person. MUST match pattern `^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$`. |
+
+#### 3.3.2 Rules
+
+- Both `role` and `fullName` are mandatory.
+- No additional properties are allowed.
+
+#### 3.3.3 Person Object Example
+
+```yaml
+- role: owner
+ fullName: "Jane Doe"
+ email: "jane.doe@example.com"
+```
+
+---
+
+### 3.4 Capability Object
+
+Defines the technical configuration of the capability.
+
+#### 3.4.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **exposes** | `Exposes[]` | List of exposed server adapters. Each entry is an API Expose (`type: "api"`) or an MCP Expose (`type: "mcp"`). |
+| **consumes** | `Consumes[]` | List of consumed client adapters. |
+
+#### 3.4.2 Rules
+
+- At least one of `exposes` or `consumes` MUST be present.
+- When present, the `exposes` array MUST contain at least one entry.
+- When present, the `consumes` array MUST contain at least one entry.
+- 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.
+- No additional properties are allowed.
+
+#### 3.4.3 Namespace Uniqueness Rule
+
+When multiple `consumes` entries are present:
+
+- Each `namespace` value MUST be unique across all consumes entries.
+- The `namespace` field is used for routing from the expose layer to the correct consumed source.
+- Duplicate namespace values will result in ambiguous routing and are forbidden.
+
+#### 3.4.4 Capability Object Example
+
+```yaml
+capability:
+ exposes:
+ - type: api
+ port: 3000
+ namespace: tasks-api
+ resources:
+ - path: /tasks
+ description: "Endpoint to create tasks via the external API"
+ operations:
+ - method: POST
+ label: Create Task
+ call: api.create-task
+ outputParameters:
+ - type: string
+ mapping: $.taskId
+ consumes:
+ - type: http
+ namespace: api
+ baseUri: https://api.example.com
+ resources:
+ - name: tasks
+ label: Tasks API
+ path: /tasks
+ operations:
+ - name: create-task
+ label: Create Task
+ method: POST
+ inputParameters:
+ - name: task_id
+ in: path
+ outputParameters:
+ - name: taskId
+ type: string
+ value: $.data.id
+```
+
+---
+
+### 3.5 Exposes Object
+
+Describes a server adapter that exposes functionality.
+
+> Update (schema v0.5): Two exposition adapter types are now supported — **API** (`type: "api"`) and **MCP** (`type: "mcp"`). Legacy `httpProxy` / `rest` exposition types are not part of the JSON Schema anymore.
+>
+
+#### 3.5.1 API Expose
+
+API exposition configuration.
+
+> Update (schema v0.5): The Exposes object is now a discriminated union (`oneOf`) between **API** (`type: "api"`, this section) and **MCP** (`type: "mcp"`, see §3.5.4). The `type` field acts as discriminator.
+>
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"api"`. |
+| **address** | `string` | Server address. Can be a hostname, IPv4, or IPv6 address. |
+| **port** | `integer` | **REQUIRED**. Port number. MUST be between 1 and 65535. |
+| **authentication** | `Authentication` | Authentication configuration. |
+| **namespace** | `string` | **REQUIRED**. Unique identifier for this exposed API. |
+| **resources** | `ExposedResource[]` | **REQUIRED**. List of exposed resources. |
+
+#### 3.5.2 ExposedResource Object
+
+An exposed resource with **operations** and/or **forward** configuration.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **path** | `string` | **REQUIRED**. Path of the resource (supports `param` placeholders). |
+| **description** | `string` | *Recommended*. Used to provide *meaningful* information about the resource. In a world of agents, context is king. |
+| **name** | `string` | Technical name for the resource (used for references, pattern `^[a-zA-Z0-9-]+$`). |
+| **label** | `string` | Display name for the resource (likely used in UIs). |
+| **inputParameters** | `ExposedInputParameter[]` | Input parameters attached to the resource. |
+| **operations** | `ExposedOperation[]` | Operations available on this resource. |
+| **forward** | `ForwardConfig` | Forwarding configuration to a consumed namespace. |
+
+#### 3.5.3 Rules
+
+- The `path` field is mandatory. The `description` field is recommended to provide meaningful context for agent discovery.
+- At least one of `operations` or `forward` MUST be present. Both can coexist on the same resource.
+- if both `operations` or `forward` are present, in case of conflict, `operations` takes precendence on `forward`.
+- No additional properties are allowed.
+
+#### 3.5.4 MCP Expose
+
+MCP Server exposition configuration. Exposes capability operations as MCP tools over Streamable HTTP or stdio transport.
+
+> New in schema v0.5.
+>
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"mcp"`. |
+| **transport** | `string` | Transport protocol. One of: `"http"` (default), `"stdio"`. `"http"` exposes a Streamable HTTP server; `"stdio"` uses stdin/stdout JSON-RPC for local IDE integration. |
+| **address** | `string` | Server address. Can be a hostname, IPv4, or IPv6 address. |
+| **port** | `integer` | **REQUIRED when transport is `"http"`**. Port number (1–65535). MUST NOT be present when transport is `"stdio"`. |
+| **namespace** | `string` | **REQUIRED**. Unique identifier for this exposed MCP server. |
+| **description** | `string` | *Recommended*. A meaningful description of the MCP server's purpose. Sent as server instructions during MCP initialization. |
+| **tools** | `McpTool[]` | **REQUIRED**. List of MCP tools exposed by this server (minimum 1). |
+
+**Rules:**
+
+- The `type` field MUST be `"mcp"`.
+- The `namespace` field is mandatory and MUST be unique across all exposes entries.
+- The `tools` array is mandatory and MUST contain at least one entry.
+- When `transport` is `"http"` (or omitted, since `"http"` is the default), the `port` field is required.
+- When `transport` is `"stdio"`, the `port` field MUST NOT be present.
+- No additional properties are allowed.
+
+#### 3.5.5 McpTool Object
+
+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`).
+>
+
+**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-]+$`. |
+| **description** | `string` | **REQUIRED**. A meaningful description of the tool. Essential for agent discovery. |
+| **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. |
+| **steps** | `OperationStep[]` | **Orchestrated mode only. REQUIRED** (at least 1 step). Sequence of calls to consumed operations. |
+| **mappings** | `StepOutputMapping[]` | **Orchestrated mode only**. Maps step outputs to the tool's output parameters. |
+| **outputParameters** (simple) | `MappedOutputParameter[]` | **Simple mode**. Output parameters mapped from the consumed operation response. |
+| **outputParameters** (orchestrated) | `OrchestratedOutputParameter[]` | **Orchestrated mode**. Output parameters with name and type. |
+
+**Modes:**
+
+**Simple mode** — direct call to a single consumed operation:
+
+- `call` is **REQUIRED**
+- `with` is optional
+- `outputParameters` are `MappedOutputParameter[]`
+- `steps` MUST NOT be present
+
+**Orchestrated mode** — multi-step orchestration:
+
+- `steps` is **REQUIRED** (at least 1 entry)
+- `mappings` is optional
+- `outputParameters` are `OrchestratedOutputParameter[]`
+- `call` and `with` MUST NOT be present
+
+**Rules:**
+
+- Both `name` and `description` are mandatory.
+- Exactly one of the two modes MUST be used (simple or orchestrated).
+- 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.
+- The `$this` context reference works the same as for ExposedOperation: `$this.{mcpNamespace}.{paramName}` accesses the tool's input parameters.
+- No additional properties are allowed.
+
+#### 3.5.6 McpToolInputParameter Object
+
+Declares an input parameter for an MCP tool. These become properties in the tool's JSON Schema input definition.
+
+> Unlike `ExposedInputParameter`, MCP tool parameters have no `in` field (no HTTP location concept) and include a `required` flag.
+>
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Parameter name. Becomes a property name in the tool's input schema. MUST match pattern `^[a-zA-Z0-9-_*]+$`. |
+| **type** | `string` | **REQUIRED**. Data type. One of: `string`, `number`, `integer`, `boolean`, `array`, `object`. |
+| **description** | `string` | **REQUIRED**. A meaningful description of the parameter. Used for agent discovery and tool documentation. |
+| **required** | `boolean` | Whether the parameter is required. Defaults to `true`. |
+
+**Rules:**
+
+- The `name`, `type`, and `description` fields are all mandatory.
+- The `type` field MUST be one of: `"string"`, `"number"`, `"integer"`, `"boolean"`, `"array"`, `"object"`.
+- The `required` field defaults to `true` when omitted.
+- No additional properties are allowed.
+
+**McpToolInputParameter Example:**
+
+```yaml
+- name: database_id
+ type: string
+ description: The unique identifier of the Notion database
+- name: page_size
+ type: number
+ description: Number of results per page (max 100)
+ required: false
+```
+
+#### 3.5.7 Address Validation Patterns
+
+- **Hostname**: `^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
+- **IPv4**: `^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`
+- **IPv6**: `^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$`
+
+#### 3.5.8 Exposes Object Examples
+
+**API Expose with operations:**
+
+```yaml
+type: api
+port: 3000
+namespace: sample
+resources:
+ - path: /status
+ description: "Health check endpoint"
+ name: health
+ operations:
+ - name: get-status
+ method: GET
+ call: api.health-check
+ outputParameters:
+ - type: string
+ mapping: $.status
+```
+
+**API Expose with forward:**
+
+```yaml
+type: api
+port: 8080
+namespace: proxy
+resources:
+ - path: /notion/{path}
+ description: "Forward requests to the Notion API"
+ forward:
+ targetNamespace: notion
+ trustedHeaders:
+ - Notion-Version
+```
+
+**API Expose with both operations and forward:**
+
+```yaml
+type: api
+port: 9090
+namespace: hybrid
+resources:
+ - path: /data/{path}
+ description: "Resource with orchestrated operations and pass-through forwarding"
+ operations:
+ - name: get-summary
+ method: GET
+ call: api.get-summary
+ forward:
+ targetNamespace: api
+ trustedHeaders:
+ - Authorization
+```
+
+**MCP Expose with a single tool:**
+
+```yaml
+type: mcp
+port: 3001
+namespace: tools
+description: "AI-facing tools for database operations"
+tools:
+ - name: get-database
+ description: "Retrieve metadata about a database by its ID"
+ inputParameters:
+ - name: database_id
+ type: string
+ description: "The unique identifier of the database"
+ call: api.get-database
+ with:
+ database_id: "$this.tools.database_id"
+```
+
+---
+
+### 3.6 Consumes Object
+
+Describes a client adapter for consuming external APIs.
+
+> Update (schema v0.5): `targetUri` is now `baseUri`. The `headers` field has been removed — use `inputParameters` with `in: "header"` instead.
+>
+
+#### 3.6.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. Type of consumer. Valid values: `"http"`. |
+| **namespace** | `string` | Path suffix used for routing from exposes. MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **baseUri** | `string` | **REQUIRED**. Base URI for the consumed API. Must be a valid http(s) URL (no `path` placeholder in the schema). |
+| **authentication** | Authentication Object | Authentication configuration. Defaults to `"inherit"`. |
+| **description** | `string` | *Recommended*. A description of the consumed API. The more meaningful it is, the easier for agent discovery. |
+| **inputParameters** | `ConsumedInputParameter[]` | Input parameters applied to all operations in this consumed API. |
+| **resources** | [ConsumedHttpResource Object] | **REQUIRED**. List of API resources. |
+
+#### 3.6.2 Rules
+
+- The `type` field MUST be `"http"`.
+- The `baseUri` field is required.
+- The `namespace` field is required and MUST be unique across all consumes entries.
+- The `namespace` value MUST match the pattern `^[a-zA-Z0-9-]+$` (alphanumeric and hyphens only).
+- The `description` field is recommended to improve agent discovery.
+- The `resources` array is required and MUST contain at least one entry.
+
+#### 3.6.3 Base URI Format
+
+The `baseUri` field MUST be a valid `http://` or `https://` URL, and may optionally include a base path.
+
+Example: `https://api.github.com` or `https://api.github.com/v3`
+
+#### 3.6.4 Consumes Object Example
+
+```yaml
+type: http
+namespace: github
+baseUri: https://api.github.com
+authentication:
+ type: bearer
+ token: "{{github_token}}"
+inputParameters:
+ - name: Accept
+ in: header
+ value: "application/vnd.github.v3+json"
+resources:
+ - name: users
+ label: Users API
+ path: /users/{username}
+ operations:
+ - name: get-user
+ label: Get User
+ method: GET
+ inputParameters:
+ - name: username
+ in: path
+ outputParameters:
+ - name: userId
+ type: string
+ value: $.id
+ - name: repos
+ label: Repositories API
+ path: /users/{username}/repos
+ operations:
+ - name: list-repos
+ label: List Repositories
+ method: GET
+ inputParameters:
+ - name: username
+ in: path
+ outputParameters:
+ - name: repos
+ type: array
+ value: $
+```
+
+---
+
+### 3.7 ConsumedHttpResource Object
+
+Describes an API resource that can be consumed from an external API.
+
+#### 3.7.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Technical name for the resource. MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **label** | `string` | Display name of the resource. |
+| **description** | `string` | Description of the resource. |
+| **path** | `string` | **REQUIRED**. Path of the resource, relative to the consumes `baseUri`. |
+| **inputParameters** | `ConsumedInputParameter[]` | Input parameters for this resource. |
+| **operations** | `ConsumedHttpOperation[]` | **REQUIRED**. List of operations for this resource. |
+
+#### 3.7.2 Rules
+
+- The `name` field MUST be unique within the parent consumes object's resources array.
+- The `name` field MUST match the pattern `^[a-zA-Z0-9-]+$` (alphanumeric and hyphens only).
+- The `path` field will be appended to the parent consumes object's `baseUri`.
+- The `operations` array MUST contain at least one entry.
+- No additional properties are allowed.
+
+#### 3.7.3 ConsumedHttpResource Object Example
+
+```yaml
+name: users
+label: Users API
+path: /users/{username}
+inputParameters:
+ - name: username
+ in: path
+operations:
+ - name: get-user
+ label: Get User
+ method: GET
+ outputParameters:
+ - name: userId
+ type: string
+ value: $.id
+```
+
+---
+
+### 3.8 ConsumedHttpOperation Object
+
+Describes an operation that can be performed on a consumed resource.
+
+#### 3.8.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Technical name for the operation. MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **label** | `string` | Display name of the operation. |
+| **description** | `string` | A longer description of the operation for documentation purposes. |
+| **method** | `string` | **REQUIRED**. HTTP method. One of: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. Default: `GET`. |
+| **inputParameters** | `ConsumedInputParameter[]` | Input parameters for the operation. |
+| **outputRawFormat** | `string` | The raw format of the response. One of: `json`, `xml`, `avro`, `protobuf`, `csv`, `yaml` . Default: `json`. |
+| **outputParameters** | `ConsumedOutputParameter[]` | Output parameters extracted from the response via JsonPath. |
+| **body** | `RequestBody` | Request body configuration. |
+
+#### 3.8.2 Rules
+
+- The `name` field MUST be unique within the parent resource's operations array.
+- The `name` field MUST match the pattern `^[a-zA-Z0-9-]+$` (alphanumeric and hyphens only).
+- Both `name` and `method` are mandatory.
+- No additional properties are allowed.
+
+#### 3.8.3 ConsumedHttpOperation Object Example
+
+```yaml
+name: get-user
+label: Get User Profile
+method: GET
+inputParameters:
+ - name: username
+ in: path
+outputParameters:
+ - name: userId
+ type: string
+ value: $.id
+ - name: username
+ type: string
+ value: $.login
+ - name: email
+ type: string
+ value: $.email
+```
+
+---
+
+### 3.9 ExposedOperation Object
+
+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.
+>
+
+#### 3.9.1 Fixed Fields
+
+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.** |
+| **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. |
+| **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. |
+| **outputParameters** (simple) | `MappedOutputParameter[]` | **Simple mode**. Output parameters mapped from the consumed operation response. |
+| **steps** | `OperationStep[]` | **Orchestrated mode only. REQUIRED** (at least 1 step). Sequence of calls to consumed operations. |
+| **outputParameters** (orchestrated) | `OrchestratedOutputParameter[]` | **Orchestrated mode**. Output parameters with name and type. |
+| **mappings** | `StepOutputMapping[]` | **Orchestrated mode only**. Maps step outputs to the operation's output parameters at the operation level. |
+
+#### 3.9.2 Modes (oneOf)
+
+**Simple mode** — A direct call to a single consumed operation:
+
+- `call` is **REQUIRED**
+- `with` is optional (inject parameters into the call)
+- `outputParameters` are `MappedOutputParameter[]` (type + mapping)
+- `steps` MUST NOT be present
+- `name` is optional
+
+**Orchestrated mode** — A multi-step orchestration:
+
+- `name` is **REQUIRED**
+- `steps` is **REQUIRED** (at least 1 entry)
+- `mappings` is optional — maps step outputs to the operation's output parameters at the operation level
+- `outputParameters` are `OrchestratedOutputParameter[]` (name + type)
+- `call` and `with` MUST NOT be present
+
+#### 3.9.3 Rules
+
+- Exactly one of the two modes MUST be used (simple or orchestrated).
+- 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}`.
+- The `method` field is always required regardless of mode.
+
+#### 3.9.4 ExposedOperation Object Examples
+
+**Simple mode (direct call):**
+
+```yaml
+method: GET
+label: Get User Profile
+call: github.get-user
+with:
+ username: $this.sample.username
+outputParameters:
+ - type: string
+ mapping: $.login
+ - type: number
+ mapping: $.id
+```
+
+**Orchestrated mode (multi-step):**
+
+```yaml
+name: get-db
+method: GET
+label: Get Database
+inputParameters:
+ - name: database_id
+ in: path
+ type: string
+ description: The ID of the database to retrieve
+steps:
+ - type: call
+ name: fetch-db
+ call: notion.get-database
+ with:
+ database_id: "$this.sample.database_id"
+mappings:
+ - targetName: db_name
+ value: "$.dbName"
+outputParameters:
+ - name: db_name
+ type: string
+ - name: Api-Version
+ type: string
+```
+
+---
+
+### 3.10 RequestBody Object
+
+Describes request body configuration for consumed operations. `RequestBody` is a `oneOf` — exactly one of five subtypes must be used.
+
+#### 3.10.1 Subtypes
+
+**RequestBodyJson** — JSON body
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"json"`. |
+| **data** | `string` | `object` | `array` | **REQUIRED**. The JSON payload. Can be a raw JSON string, an inline object, or an array. |
+
+**RequestBodyText** — Plain text, XML, or SPARQL body
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. One of: `"text"`, `"xml"`, `"sparql"`. |
+| **data** | `string` | **REQUIRED**. The text payload. |
+
+**RequestBodyFormUrlEncoded** — URL-encoded form body
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"formUrlEncoded"`. |
+| **data** | `string` | `object` | **REQUIRED**. Either a raw URL-encoded string or an object whose values are strings. |
+
+**RequestBodyMultipartForm** — Multipart form body
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"multipartForm"`. |
+| **data** | `RequestBodyMultipartFormPart[]` | **REQUIRED**. Array of form parts. Each part has: `name` (required), `value` (required), `filename` (optional), `contentType` (optional). |
+
+**RequestBodyRaw** — Raw string body
+
+`RequestBody` can also be a plain `string`. When used with a YAML block scalar (`|`), the string is sent as-is. Interpreted as JSON by default.
+
+#### 3.10.2 Rules
+
+- Exactly one of the five subtypes must be used.
+- For structured subtypes, both `type` and `data` are mandatory.
+- No additional properties are allowed on any subtype.
+
+#### 3.10.3 RequestBody Examples
+
+**JSON body (object):**
+
+```yaml
+body:
+ type: json
+ data:
+ hello: "world"
+```
+
+**JSON body (string):**
+
+```yaml
+body:
+ type: json
+ data: '{"key": "value"}'
+```
+
+**Text body:**
+
+```yaml
+body:
+ type: text
+ data: "Hello, world!"
+```
+
+**Form URL-encoded body:**
+
+```yaml
+body:
+ type: formUrlEncoded
+ data:
+ username: "admin"
+ password: "secret"
+```
+
+**Multipart form body:**
+
+```yaml
+body:
+ type: multipartForm
+ data:
+ - name: "file"
+ value: "base64content..."
+ filename: "document.pdf"
+ contentType: "application/pdf"
+ - name: "description"
+ value: "My uploaded file"
+```
+
+**Raw body:**
+
+```yaml
+body: |
+ {
+ "filter": {
+ "property": "Status",
+ "select": { "equals": "Active" }
+ }
+ }
+```
+
+---
+
+### 3.11 InputParameter Objects
+
+> Update (schema v0.5): The single `InputParameter` object has been split into two distinct types: **ConsumedInputParameter** (used in consumes) and **ExposedInputParameter** (used in exposes, with additional `type` and `description` fields required).
+>
+
+#### 3.11.1 ConsumedInputParameter Object
+
+Used in consumed resources and operations.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Parameter name. MUST match pattern `^[a-zA-Z0-9-*]+$`. |
+| **in** | `string` | **REQUIRED**. Parameter location. Valid values: `"query"`, `"header"`, `"path"`, `"cookie"`, `"body"`. |
+| **value** | `string` | Value or JSONPath reference. |
+
+**Rules:**
+
+- Both `name` and `in` are mandatory.
+- The `name` field MUST match the pattern `^[a-zA-Z0-9-*]+$`.
+- The `in` field MUST be one of: `"query"`, `"header"`, `"path"`, `"cookie"`, `"body"`.
+- A unique parameter is defined by the combination of `name` and `in`.
+- No additional properties are allowed.
+
+**ConsumedInputParameter Example:**
+
+```yaml
+- name: username
+ in: path
+- name: page
+ in: query
+- name: Authorization
+ in: header
+ value: "Bearer token"
+```
+
+#### 3.11.2 ExposedInputParameter Object
+
+Used in exposed resources and operations. Extends the consumed variant with `type` (required) and `description` (recommended) for agent discoverability, plus an optional `pattern` for validation.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Parameter name. MUST match pattern `^[a-zA-Z0-9-*]+$`. |
+| **in** | `string` | **REQUIRED**. Parameter location. Valid values: `"query"`, `"header"`, `"path"`, `"cookie"`, `"body"`. |
+| **type** | `string` | **REQUIRED**. Data type of the parameter. One of: `string`, `number`, `integer`, `boolean`, `object`, `array`. |
+| **description** | `string` | *Recommended*. Human-readable description of the parameter. Provides valuable context for agent discovery. |
+| **pattern** | `string` | Optional regex pattern for parameter value validation. |
+| **value** | `string` | Default value or JSONPath reference. |
+
+**Rules:**
+
+- The `name`, `in`, and `type` fields are mandatory. The `description` field is recommended for agent discovery.
+- The `name` field MUST match the pattern `^[a-zA-Z0-9-*]+$`.
+- The `in` field MUST be one of: `"query"`, `"header"`, `"path"`, `"cookie"`, `"body"`.
+- The `type` field MUST be one of: `"string"`, `"number"`, `"integer"`, `"boolean"`, `"object"`, `"array"`.
+- No additional properties are allowed.
+
+**ExposedInputParameter Example:**
+
+```yaml
+- name: database_id
+ in: path
+ type: string
+ description: The unique identifier of the Notion database
+ pattern: "^[a-f0-9-]+$"
+- name: page_size
+ in: query
+ type: number
+ description: Number of results per page (max 100)
+```
+
+---
+
+### 3.12 OutputParameter Objects
+
+> Update (schema v0.5): The single `OutputParameter` object has been split into three distinct types: **ConsumedOutputParameter** (used in consumed operations), **MappedOutputParameter** (used in simple-mode exposed operations), and **OrchestratedOutputParameter** (used in orchestrated-mode exposed operations).
+>
+
+#### 3.12.1 ConsumedOutputParameter Object
+
+Used in consumed operations to extract values from the raw API response.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Output parameter name. MUST match pattern `^[a-zA-Z0-9-_]+$`. |
+| **type** | `string` | **REQUIRED**. Data type. One of: `string`, `number`, `boolean`, `object`, `array`. |
+| **value** | `string` | **REQUIRED**. JsonPath expression to extract value from consumed function response. |
+
+**Rules:**
+
+- All three fields (`name`, `type`, `value`) are mandatory.
+- The `name` field MUST match the pattern `^[a-zA-Z0-9-_*]+$`.
+- The `value` field MUST start with `$`.
+- No additional properties are allowed.
+
+**ConsumedOutputParameter Example:**
+
+```yaml
+outputParameters:
+ - name: dbName
+ type: string
+ value: $.title[0].text.content
+ - name: dbId
+ type: string
+ value: $.id
+```
+
+#### 3.12.2 MappedOutputParameter Object
+
+Used in **simple mode** exposed operations. Maps a value from the consumed response using `type` and `mapping`.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. Data type. One of: `string`, `number`, `boolean`, `object`, `array`. |
+| **mapping** | `string` | `object` | **REQUIRED**. For scalar types (`string`, `number`, `boolean`): a JsonPath string. For `object`: an object with `properties` (recursive MappedOutputParameter map). For `array`: an object with `items` (recursive MappedOutputParameter). |
+
+**Subtypes by type:**
+
+- **`string`**, **`number`**, **`boolean`**: `mapping` is a JSONPath string (e.g. `$.login`)
+- **`object`**: `mapping` is `{ properties: { key: MappedOutputParameter, ... } }` — recursive
+- **`array`**: `mapping` is `{ items: MappedOutputParameter }` — recursive
+
+**Rules:**
+
+- Both `type` and `mapping` are mandatory.
+- No additional properties are allowed.
+
+**MappedOutputParameter Examples:**
+
+```yaml
+# Scalar mapping
+outputParameters:
+ - type: string
+ mapping: $.login
+ - type: number
+ mapping: $.id
+
+# Object mapping (recursive)
+outputParameters:
+ - type: object
+ mapping:
+ properties:
+ username:
+ type: string
+ mapping: $.login
+ userId:
+ type: number
+ mapping: $.id
+
+# Array mapping (recursive)
+outputParameters:
+ - type: array
+ mapping:
+ items:
+ type: object
+ mapping:
+ properties:
+ name:
+ type: string
+ mapping: $.name
+```
+
+#### 3.12.3 OrchestratedOutputParameter Object
+
+Used in **orchestrated mode** exposed operations. Declares an output by `name` and `type` (the value is populated via step mappings).
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Output parameter name. |
+| **type** | `string` | **REQUIRED**. Data type. One of: `string`, `number`, `boolean`, `object`, `array`. |
+
+**Subtypes by type:**
+
+- **`string`**, **`number`**, **`boolean`** (scalar): only `name` and `type`
+- **`array`**: adds `items` (recursive OrchestratedOutputParameter without `name`)
+- **`object`**: adds `properties` (map of name → recursive OrchestratedOutputParameter without `name`)
+
+**Rules:**
+
+- Both `name` and `type` are mandatory.
+- No additional properties are allowed.
+
+**OrchestratedOutputParameter Example:**
+
+```yaml
+outputParameters:
+ - name: db_name
+ type: string
+ - name: Api-Version
+ type: string
+ - name: results
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ title:
+ type: string
+```
+
+#### 3.12.4 JSONPath roots (extensions)
+
+In a consumed resource, **`$`** refers to the *raw response payload* of the consumed operation (after decoding based on `outputRawFormat`). The root `$` gives direct access to the JSON response body.
+
+Example, if you consider the following JSON response :
+
+```json
+{
+ "id": "154548",
+ "titles": [
+ {
+ "text": {
+ "content": "This is title[0].text.content",
+ "author": "user1"
+ }
+ }
+ ],
+ "created_time": "2024-06-01T12:00:00Z"
+}
+```
+
+- `$.id` is `154548`
+- `$.titles[0].text.content` is `This is title[0].text.content`
+
+#### 3.12.5 Common patterns
+
+- `$.fieldName` — accesses a top-level field
+- `$.data.user.id` — accesses nested fields
+- `$.items[0]` — accesses array elements
+- `$.items[*].id` — accesses all ids in an array
+
+---
+
+### 3.13 OperationStep Object
+
+Describes a single step in an orchestrated operation. `OperationStep` is a `oneOf` between two subtypes: **OperationStepCall** and **OperationStepLookup**, both sharing a common **OperationStepBase**.
+
+> Update (schema v0.5): OperationStep is now a discriminated union (`oneOf`) with a required `type` field (`"call"` or `"lookup"`) and a required `name` field. `OperationStepCall` uses `with` (WithInjector) instead of `inputParameters`. `OperationStepLookup` is entirely new.
+>
+
+#### 3.13.1 OperationStepBase (shared fields)
+
+All operation steps share these base fields:
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. Step type discriminator. One of: `"call"`, `"lookup"`. |
+| **name** | `string` | **REQUIRED**. Technical name for the step (pattern `^[a-zA-Z0-9-]+$`). Used as namespace for referencing step outputs in mappings and expressions. |
+
+#### 3.13.2 OperationStepCall
+
+Calls a consumed operation.
+
+**Fixed Fields** (in addition to base):
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"call"`. |
+| **name** | `string` | **REQUIRED**. Step name (from base). |
+| **call** | `string` | **REQUIRED**. Reference to consumed operation. Format: `{namespace}.{operationId}`. MUST match pattern `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+$`. |
+| **with** | `WithInjector` | Parameter injection for the called operation. Keys are parameter names, values are strings or numbers (static values or `$this` references). |
+
+**Rules:**
+
+- `type`, `name`, and `call` are mandatory.
+- The `call` field MUST follow the format `{namespace}.{operationName}`.
+- The `namespace` portion MUST correspond to a namespace defined in one of the capability's consumes entries.
+- The `operationName` portion MUST correspond to an operation `name` defined in the consumes entry identified by the namespace.
+- `with` uses the same `WithInjector` object as simple-mode ExposedOperation (see §3.18).
+- No additional properties are allowed.
+
+#### 3.13.3 OperationStepLookup
+
+Performs a lookup against the output of a previous call step, matching values and extracting fields.
+
+**Fixed Fields** (in addition to base):
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"lookup"`. |
+| **name** | `string` | **REQUIRED**. Step name (from base). |
+| **index** | `string` | **REQUIRED**. Name of a previous call step whose output serves as the lookup table. MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **match** | `string` | **REQUIRED**. Name of the key field in the index to match against. MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **lookupValue** | `string` | **REQUIRED**. JSONPath expression resolving to the value(s) to look up. |
+| **outputParameters** | `string[]` | **REQUIRED**. List of field names to extract from the matched index entries (minimum 1 entry). |
+
+**Rules:**
+
+- `type`, `name`, `index`, `match`, `lookupValue`, and `outputParameters` are all mandatory.
+- `outputParameters` MUST contain at least one entry.
+- The `index` value MUST reference the `name` of a previous `call` step in the same orchestration.
+- No additional properties are allowed.
+
+#### 3.13.4 Call Reference Resolution
+
+The `call` value on an `OperationStepCall` is resolved as follows:
+
+1. Split the value on the `.` character into namespace and operationName
+2. Find the consumes entry with matching `namespace` field
+3. Within that consumes entry's resources, find the operation with matching `name` field
+4. Execute that operation as part of the orchestration sequence
+
+#### 3.13.5 OperationStep Object Examples
+
+**Call step with parameter injection:**
+
+```yaml
+steps:
+ - type: call
+ name: fetch-db
+ call: notion.get-database
+ with:
+ database_id: $this.sample.database_id
+```
+
+**Lookup step (match against a previous call's output):**
+
+```yaml
+steps:
+ - type: call
+ name: list-users
+ call: github.list-users
+ - type: lookup
+ name: find-user
+ index: list-users
+ match: email
+ lookupValue: $this.sample.user_email
+ outputParameters:
+ - login
+ - id
+```
+
+**Multi-step orchestration (call + lookup):**
+
+```yaml
+steps:
+ - type: call
+ name: get-entries
+ call: api.list-entries
+ - type: lookup
+ name: resolve-entry
+ index: get-entries
+ match: entry_id
+ lookupValue: $this.sample.target_id
+ outputParameters:
+ - title
+ - status
+ - type: call
+ name: post-result
+ call: slack.post-message
+ with:
+ text: $this.sample.title
+```
+
+---
+
+### 3.14 StepOutputMapping Object
+
+Describes how to map the output of an operation step to the input of another step or to the output of the exposed operation.
+
+#### 3.14.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **targetName** | `string` | **REQUIRED**. The name of the parameter to map to. It can be an input parameter of a next step or an output parameter of the exposed operation. |
+| **value** | `string` | **REQUIRED**. A JSONPath reference to the value to map from. E.g. `$.get-database.database_id`. |
+
+#### 3.14.2 Rules
+
+- Both `targetName` and `value` are mandatory.
+- No additional properties are allowed.
+
+#### 3.14.3 How mappings wire steps to exposed outputs
+
+A StepOutputMapping connects the **output parameters of a consumed operation** (called by the step) to the **output parameters of the exposed operation** (or to input parameters of subsequent steps).
+
+- **`targetName`** — refers to the `name` of an output parameter declared on the exposed operation, or the `name` of an input parameter of a subsequent step. The target parameter receives its value from the mapping.
+- **`value`** — a JSONPath expression where **`$`** is the root of the consumed operation's output parameters. The syntax `$.{outputParameterName}` references a named output parameter of the consumed operation called in this step.
+
+#### 3.14.4 End-to-end example
+
+Consider a consumed operation `notion.get-database` that declares:
+
+```yaml
+# In consumes → resources → operations
+name: "get-database"
+outputParameters:
+ - name: "dbName"
+ value: "$.title[0].text.content"
+```
+
+And the exposed side of the capability:
+
+```yaml
+# In exposes
+exposes:
+ - type: "api"
+ address: "localhost"
+ port: 9090
+ namespace: "sample"
+ resources:
+ - path: "/databases/{database_id}"
+ name: "db"
+ label: "Database resource"
+ description: "Retrieve information about a Notion database"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ type: "string"
+ description: "The unique identifier of the Notion database"
+ operations:
+ - name: "get-db"
+ method: "GET"
+ label: "Get Database"
+ outputParameters:
+ - name: "db_name"
+ type: "string"
+ steps:
+ - type: "call"
+ name: "fetch-db"
+ call: "notion.get-database"
+ with:
+ database_id: "$this.sample.database_id"
+ mappings:
+ - targetName: "db_name"
+ value: "$.dbName"
+```
+
+Here is what happens at orchestration time:
+
+1. The step `fetch-db` calls `notion.get-database`, which extracts `dbName` and `dbId` from the raw response via its own output parameters.
+2. The `with` injector passes `database_id` from the exposed input parameter (`$this.sample.database_id`) to the consumed operation.
+3. The mapping `targetName: "db_name"` refers to the exposed operation's output parameter `db_name`.
+4. The mapping `value: "$.dbName"` resolves to the value of the consumed operation's output parameter named `dbName`.
+5. As a result, the exposed output `db_name` is populated with the value extracted by `$.dbName` (i.e. `title[0].text.content` from the raw Notion API response).
+
+#### 3.14.5 StepOutputMapping Object Example
+
+```yaml
+mappings:
+ - targetName: "db_name"
+ value: "$.dbName"
+```
+
+---
+
+### 3.15 `$this` Context Reference
+
+Describes how `$this` references work in `with` (WithInjector) and other expression contexts.
+
+> Update (schema v0.5): The former `OperationStepParameter` object (with `name` and `value` fields) has been replaced by `WithInjector` (see §3.18). This section now documents the `$this` expression root, which is used within `WithInjector` values.
+>
+
+#### 3.15.1 The `$this` root
+
+In a `with` (WithInjector) value — whether on an ExposedOperation (simple mode) or an OperationStepCall — the **`$this`** root references the *current capability execution context*, i.e. values already resolved during orchestration.
+
+**`$this`** navigates the expose layer's input parameters using the path `$this.{exposeNamespace}.{inputParameterName}`. This allows a step or a simple-mode call to receive values that were provided by the caller of the exposed operation.
+
+- **`$this.{exposeNamespace}.{paramName}`** — accesses an input parameter of the exposed resource or operation identified by its namespace.
+- The `{exposeNamespace}` corresponds to the `namespace` of the exposed API.
+- The `{paramName}` corresponds to the `name` of an input parameter declared on the exposed resource or operation.
+
+#### 3.15.2 Example
+
+If the exposed API has namespace `sample` and an input parameter `database_id` declared on its resource, then:
+
+- `$this.sample.database_id` resolves to the value of `database_id` provided by the caller.
+
+**Usage in a WithInjector:**
+
+```yaml
+call: notion.get-database
+with:
+ database_id: $this.sample.database_id
+```
+
+---
+
+### 3.16 Authentication Object
+
+Defines authentication configuration. Four types are supported: basic, apikey, bearer, and digest.
+
+#### 3.16.1 Basic Authentication
+
+HTTP Basic Authentication.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"basic"`. |
+| **username** | `string` | Username for basic auth. |
+| **password** | `string` | Password for basic auth. |
+
+**Example:**
+
+```yaml
+authentication:
+ type: basic
+ username: admin
+ password: "secret_password"
+```
+
+#### 3.16.2 API Key Authentication
+
+API Key authentication via header or query parameter.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"apikey"`. |
+| **key** | `string` | API key name (header name or query parameter name). |
+| **value** | `string` | API key value. |
+| **placement** | `string` | Where to place the key. Valid values: `"header"`, `"query"`. |
+
+**Example:**
+
+```yaml
+authentication:
+ type: apikey
+ key: X-API-Key
+ value: "{{api_key}}"
+ placement: header
+```
+
+#### 3.16.3 Bearer Token Authentication
+
+Bearer token authentication.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"bearer"`. |
+| **token** | `string` | Bearer token value. |
+
+**Example:**
+
+```yaml
+authentication:
+ type: bearer
+ token: "bearer_token"
+```
+
+#### 3.16.4 Digest Authentication
+
+HTTP Digest Authentication.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **type** | `string` | **REQUIRED**. MUST be `"digest"`. |
+| **username** | `string` | Username for digest auth. |
+| **password** | `string` | Password for digest auth. |
+
+**Example:**
+
+```yaml
+authentication:
+ type: digest
+ username: admin
+ password: "secret_password"
+```
+
+#### 3.16.5 Rules
+
+- Only one authentication type can be used per authentication object.
+- The `type` field determines which additional fields are required or allowed.
+- Authentication can be specified at multiple levels (exposes, consumes) with inner levels overriding outer levels.
+
+---
+
+### 3.17 ForwardConfig Object
+
+Defines forwarding configuration for an exposed resource to pass requests through to a consumed namespace.
+
+> Update (schema v0.5): Renamed from `ForwardHeaders` to `ForwardConfig`. The `targetNamespaces` array has been replaced by a single `targetNamespace` string.
+>
+
+#### 3.17.1 Fixed Fields
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **targetNamespace** | `string` | **REQUIRED**. The consumer namespace to forward requests to. MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **trustedHeaders** | [`string`] | **REQUIRED**. List of headers allowed to be forwarded (minimum 1 entry). No wildcards supported. |
+
+#### 3.17.2 Rules
+
+- The `targetNamespace` field is mandatory and MUST reference a valid namespace from one of the capability's consumes entries.
+- The `trustedHeaders` array is mandatory and MUST contain at least one entry.
+- Header names in `trustedHeaders` are case-insensitive (following HTTP header conventions).
+- Only headers listed in `trustedHeaders` will be forwarded to the consumed source.
+- No additional properties are allowed.
+
+#### 3.17.3 ForwardConfig Object Example
+
+```yaml
+forward:
+ targetNamespace: notion
+ trustedHeaders:
+ - Authorization
+ - Notion-Version
+```
+
+---
+
+### 3.18 WithInjector Object
+
+Defines parameter injection for simple-mode exposed operations. Used with the `with` field on an ExposedOperation to inject values into the called consumed operation.
+
+> New in schema v0.5.
+>
+
+#### 3.18.1 Shape
+
+`WithInjector` is an object whose keys are parameter names and whose values are static values or `$this` references.
+
+- Each key corresponds to a parameter `name` in the consumed operation's `inputParameters`.
+- Each value is a `string` or a `number`: either a static value or a `$this.{namespace}.{paramName}` reference.
+
+#### 3.18.2 Rules
+
+- The keys MUST correspond to valid parameter names in the consumed operation being called.
+- Values can be strings or numbers.
+- String values can use the `$this` root to reference exposed input parameters (same as in OperationStepParameter).
+- No additional constraints.
+
+#### 3.18.3 WithInjector Object Example
+
+```yaml
+call: github.get-user
+with:
+ username: $this.sample.username
+ Accept: "application/json"
+ maxRetries: 3
+```
+
+---
+
+### 3.19 ExternalRef Object
+
+> **Updated**: ExternalRef is now a discriminated union (`oneOf`) with two variants — **file-resolved** (for local development) and **runtime-resolved** (for production). Variables are explicitly declared via a `keys` map.
+>
+
+Declares an external reference that provides variables to the capability. External references are declared at the root level of the Naftiko document via the `externalRefs` array.
+
+`ExternalRef` is a `oneOf` — exactly one of the two variants must be used.
+
+#### 3.19.1 File-Resolved ExternalRef
+
+Loads variables from a local file. Intended for **local development only**.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Unique identifier (kebab-case). MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **description** | `string` | *Recommended*. Used to provide *meaningful* information about the external reference. In a world of agents, context is king. |
+| **type** | `string` | **REQUIRED**. MUST be `"environment"`. |
+| **resolution** | `string` | **REQUIRED**. MUST be `"file"`. |
+| **uri** | `string` | **REQUIRED**. URI pointing to the file (e.g. `file:///path/to/env.json`). |
+| **keys** | `ExternalRefKeys` | **REQUIRED**. Map of variable names to keys in the resolved file content. |
+
+**Rules:**
+
+- The `name`, `type`, `resolution`, `uri`, and `keys` fields are mandatory. The `description` field is recommended.
+- No additional properties are allowed.
+
+#### 3.19.2 Runtime-Resolved ExternalRef
+
+Variables are injected by the execution environment at startup (default). The capability document does **not** specify where the values come from — this is delegated to the deployment platform.
+
+**Fixed Fields:**
+
+| Field Name | Type | Description |
+| --- | --- | --- |
+| **name** | `string` | **REQUIRED**. Unique identifier (kebab-case). MUST match pattern `^[a-zA-Z0-9-]+$`. |
+| **type** | `string` | **REQUIRED**. MUST be `"environment"`. |
+| **resolution** | `string` | **REQUIRED.** MUST be `"runtime"`. |
+| **keys** | `ExternalRefKeys` | **REQUIRED**. Map of variable names to keys in the runtime context. |
+
+**Rules:**
+
+- `name`, `type`, and `keys` are mandatory.
+- `resolution` is optional; when present MUST be `"runtime"`.
+- No additional properties are allowed.
+
+Typical production providers include:
+
+- **HashiCorp Vault** — centralized secrets management
+- **Kubernetes Secrets** / **ConfigMaps** — native K8s secret injection
+- **AWS Secrets Manager** / **AWS SSM Parameter Store**
+- **Azure Key Vault**
+- **GCP Secret Manager**
+- **Docker Secrets** — for containerized deployments
+- **CI/CD pipeline variables** (GitHub Actions secrets, GitLab CI variables, etc.)
+
+#### 3.19.3 ExternalRefKeys Object
+
+A map of key-value pairs that define the variables to be injected from the external reference.
+
+- Each **key** is the variable name used for injection (available as `\{\{key\}\}` in the capability definition)
+- Each **value** is the corresponding key in the resolved file content or runtime context
+
+Example: `{"notion_token": "NOTION_INTEGRATION_TOKEN"}` means the value of `NOTION_INTEGRATION_TOKEN` in the source will be injected as `{{notion_token}}` in the capability definition.
+
+**Schema:**
+
+```json
+{
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+}
+```
+
+#### 3.19.4 Rules
+
+- Each `name` value MUST be unique across all `externalRefs` entries.
+- The `name` value MUST NOT collide with any `consumes` namespace to avoid ambiguity.
+- The `keys` map MUST contain at least one entry.
+- Variable names (keys in the `keys` map) SHOULD be unique across all `externalRefs` entries. If the same variable name appears in multiple entries, the expression MUST use the qualified form `{{name.variable}}` (where `name` is the `name` of the `externalRefs` entry) to disambiguate which source provides the value.
+- No additional properties are allowed on either variant.
+
+
+⚠️
+
+**Security recommendation** — Never use `resolution: "file"` in production environments. File-based resolution exposes secret locations in the capability document and creates a risk of accidental secret leakage (e.g. committing `.env` files). Always use `resolution: "runtime"` in production, with secrets injected by a dedicated secrets manager.
+
+
+
+#### 3.19.5 ExternalRef Object Examples
+
+**File resolution (development):**
+
+```yaml
+externalRefs:
+ - name: "notion-env"
+ type: "environment"
+ description: "External reference to Notion API for accessing project data stored in Notion."
+ resolution: file
+ uri: "file:///path/to/notion_env.json"
+ keys:
+ notion_token: "NOTION_INTEGRATION_TOKEN"
+ notion_projects_db_id: "PROJECTS_DATABASE_ID"
+ notion_time_tracker_db_id: "TIME_TRACKER_DATABASE_ID"
+```
+
+**Runtime resolution (production):**
+
+```yaml
+externalRefs:
+ - name: "secrets"
+ type: "environment"
+ resolution: runtime
+ keys:
+ notion_token: "NOTION_INTEGRATION_TOKEN"
+ github_token: "GITHUB_TOKEN"
+```
+
+**Minimal runtime (resolution omitted — defaults to runtime):**
+
+```yaml
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ api_key: "API_KEY"
+```
+
+---
+
+### 3.20 Expression Syntax
+
+Variables declared in `externalRefs` via the `keys` map are injected into the capability document using mustache-style `\{\{variable\}\}` expressions.
+
+#### 3.20.1 Format
+
+The expression format is `\{\{key\}\}`, where `key` is a variable name declared in the `keys` map of an `externalRefs` entry.
+
+Expressions can appear in any `string` value within the document, including authentication tokens, header values, and input parameter values.
+
+#### 3.20.2 Resolution
+
+At runtime, expressions are resolved as follows:
+
+1. Find the `externalRefs` entry whose `keys` map contains the referenced variable name
+2. Look up the corresponding source key in the `keys` map
+3. Resolve the source key value using the strategy defined by `resolution` (`file` lookup or `runtime` injection)
+4. Replace the `\{\{variable\}\}` expression with the resolved value
+
+If a referenced variable is not declared in any `externalRefs` entry's `keys`, the document MUST be considered invalid.
+
+#### 3.20.3 Relationship with `$this`
+
+`\{\{variable\}\}` expressions and `$this` references serve different purposes:
+
+- `\{\{variable\}\}` resolves **static configuration** from external references (secrets, environment variables) declared via `keys`
+- `$this.{exposeNamespace}.{paramName}` resolves **runtime orchestration** values from the expose layer's input parameters
+
+The two expression systems are independent and MUST NOT be mixed.
+
+#### 3.20.4 Expression Examples
+
+```yaml
+# Authentication token from external ref
+authentication:
+ type: bearer
+ token: "{{notion_token}}"
+
+# Input parameter with header value from external ref
+inputParameters:
+ - name: Notion-Version
+ in: header
+ value: "{{notion_version}}"
+
+# Corresponding externalRefs declaration
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ notion_token: "NOTION_TOKEN"
+ api_key: "API_KEY"
+```
+
+---
+
+## 4. Complete Examples
+
+This section provides progressive examples — from the simplest capability to a full-featured one — to illustrate the main patterns of the specification. All examples are pseudo-functional and use realistic API shapes.
+
+### 4.1 Forward-only capability (proxy)
+
+The simplest capability: forward incoming requests to a consumed API without any transformation.
+
+```yaml
+---
+naftiko: "0.5"
+info:
+ label: "Notion Proxy"
+ description: "Pass-through proxy to the Notion API for development and debugging"
+ tags:
+ - proxy
+ - notion
+ created: "2026-02-01"
+ modified: "2026-02-01"
+
+capability:
+ exposes:
+ - type: "api"
+ port: 8080
+ namespace: "proxy"
+ resources:
+ - path: "/notion/{path}"
+ description: "Forwards all requests to the Notion API"
+ forward:
+ targetNamespace: "notion"
+ trustedHeaders:
+ - "Authorization"
+ - "Notion-Version"
+
+ consumes:
+ - type: "http"
+ namespace: "notion"
+ description: "Notion public API"
+ baseUri: "https://api.notion.com/v1"
+ resources:
+ - name: "all"
+ path: "/{path}"
+ operations:
+ - name: "any"
+ method: "GET"
+```
+
+### 4.2 Simple-mode capability (direct call)
+
+A single exposed operation that directly calls a consumed operation, maps parameters with `with`, and extracts output.
+
+```yaml
+---
+naftiko: "0.5"
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ github_token: "GITHUB_TOKEN"
+info:
+ label: "GitHub User Lookup"
+ description: "Exposes a simplified endpoint to retrieve GitHub user profiles"
+ tags:
+ - github
+ - users
+ created: "2026-02-01"
+ modified: "2026-02-01"
+
+capability:
+ exposes:
+ - type: "api"
+ port: 3000
+ namespace: "app"
+ resources:
+ - path: "/users/{username}"
+ description: "Look up a GitHub user by username"
+ name: "user"
+ inputParameters:
+ - name: "username"
+ in: "path"
+ type: "string"
+ description: "The GitHub username to look up"
+ operations:
+ - method: "GET"
+ label: "Get User"
+ call: "github.get-user"
+ with:
+ username: "$this.app.username"
+ outputParameters:
+ - type: "string"
+ mapping: "$.login"
+ - type: "string"
+ mapping: "$.email"
+ - type: "number"
+ mapping: "$.id"
+
+ consumes:
+ - type: "http"
+ namespace: "github"
+ description: "GitHub REST API v3"
+ baseUri: "https://api.github.com"
+ authentication:
+ type: "bearer"
+ token: "{{github_token}}"
+ resources:
+ - name: "users"
+ path: "/users/{username}"
+ label: "Users"
+ operations:
+ - name: "get-user"
+ label: "Get User"
+ method: "GET"
+ inputParameters:
+ - name: "username"
+ in: "path"
+ outputParameters:
+ - name: "login"
+ type: "string"
+ value: "$.login"
+ - name: "email"
+ type: "string"
+ value: "$.email"
+ - name: "id"
+ type: "number"
+ value: "$.id"
+```
+
+### 4.3 Orchestrated capability (multi-step call)
+
+An exposed operation that chains two consumed operations using named steps and `with`.
+
+```yaml
+---
+naftiko: "0.5"
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ notion_token: "NOTION_TOKEN"
+info:
+ label: "Database Inspector"
+ description: "Retrieves a Notion database then queries its contents in a single exposed operation"
+ tags:
+ - notion
+ - orchestration
+ created: "2026-02-10"
+ modified: "2026-02-10"
+
+capability:
+ exposes:
+ - type: "api"
+ port: 9090
+ namespace: "inspector"
+ resources:
+ - path: "/databases/{database_id}/summary"
+ description: "Returns database metadata and first page of results"
+ name: "db-summary"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ type: "string"
+ description: "The Notion database ID"
+ operations:
+ - name: "get-summary"
+ method: "GET"
+ label: "Get Database Summary"
+ steps:
+ - type: "call"
+ name: "fetch-db"
+ call: "notion.get-database"
+ with:
+ database_id: "$this.inspector.database_id"
+ - type: "call"
+ name: "query-db"
+ call: "notion.query-database"
+ with:
+ database_id: "$this.inspector.database_id"
+ mappings:
+ - targetName: "db_name"
+ value: "$.fetch-db.dbName"
+ - targetName: "row_count"
+ value: "$.query-db.resultCount"
+ outputParameters:
+ - name: "db_name"
+ type: "string"
+ - name: "row_count"
+ type: "number"
+
+ consumes:
+ - type: "http"
+ namespace: "notion"
+ description: "Notion public API"
+ baseUri: "https://api.notion.com/v1"
+ authentication:
+ type: "bearer"
+ token: "{{notion_token}}"
+ inputParameters:
+ - name: "Notion-Version"
+ in: "header"
+ value: "2022-06-28"
+ resources:
+ - name: "databases"
+ path: "/databases/{database_id}"
+ label: "Databases"
+ operations:
+ - name: "get-database"
+ label: "Get Database"
+ method: "GET"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ outputParameters:
+ - name: "dbName"
+ type: "string"
+ value: "$.title[0].text.content"
+ - name: "dbId"
+ type: "string"
+ value: "$.id"
+ - name: "queries"
+ path: "/databases/{database_id}/query"
+ label: "Database queries"
+ operations:
+ - name: "query-database"
+ label: "Query Database"
+ method: "POST"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ outputParameters:
+ - name: "resultCount"
+ type: "number"
+ value: "$.results.length()"
+ - name: "results"
+ type: "array"
+ value: "$.results"
+```
+
+### 4.4 Orchestrated capability with lookup step
+
+Demonstrates a `lookup` step that cross-references the output of a previous call to enrich data.
+
+```yaml
+---
+naftiko: "0.5"
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ hr_api_key: "HR_API_KEY"
+info:
+ label: "Team Member Resolver"
+ description: "Resolves team member details by matching email addresses from a project tracker"
+ tags:
+ - hr
+ - lookup
+ created: "2026-02-15"
+ modified: "2026-02-15"
+
+capability:
+ exposes:
+ - type: "api"
+ port: 4000
+ namespace: "team"
+ resources:
+ - path: "/resolve/{email}"
+ description: "Finds a team member by email and returns their profile"
+ name: "resolve"
+ inputParameters:
+ - name: "email"
+ in: "path"
+ type: "string"
+ description: "Email address to look up"
+ operations:
+ - name: "resolve-member"
+ method: "GET"
+ label: "Resolve Team Member"
+ steps:
+ - type: "call"
+ name: "list-members"
+ call: "hr.list-employees"
+ - type: "lookup"
+ name: "find-member"
+ index: "list-members"
+ match: "email"
+ lookupValue: "$this.team.email"
+ outputParameters:
+ - "fullName"
+ - "department"
+ - "role"
+ mappings:
+ - targetName: "name"
+ value: "$.find-member.fullName"
+ - targetName: "department"
+ value: "$.find-member.department"
+ - targetName: "role"
+ value: "$.find-member.role"
+ outputParameters:
+ - name: "name"
+ type: "string"
+ - name: "department"
+ type: "string"
+ - name: "role"
+ type: "string"
+
+ consumes:
+ - type: "http"
+ namespace: "hr"
+ description: "Internal HR system API"
+ baseUri: "https://hr.internal.example.com/api"
+ authentication:
+ type: "apikey"
+ key: "X-Api-Key"
+ value: "{{hr_api_key}}"
+ placement: "header"
+ resources:
+ - name: "employees"
+ path: "/employees"
+ label: "Employees"
+ operations:
+ - name: "list-employees"
+ label: "List All Employees"
+ method: "GET"
+ outputParameters:
+ - name: "email"
+ type: "string"
+ value: "$.items[*].email"
+ - name: "fullName"
+ type: "string"
+ value: "$.items[*].name"
+ - name: "department"
+ type: "string"
+ value: "$.items[*].department"
+ - name: "role"
+ type: "string"
+ value: "$.items[*].role"
+```
+
+### 4.5 Full-featured capability (mixed modes)
+
+Combines forward proxy, simple-mode operations, orchestrated multi-step with lookup, and multiple consumed sources.
+
+```yaml
+---
+naftiko: "0.5"
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ notion_token: "NOTION_TOKEN"
+ github_token: "GITHUB_TOKEN"
+info:
+ label: "Project Dashboard"
+ description: "Aggregates project data from Notion and GitHub into a unified API, with a pass-through proxy for direct access"
+ tags:
+ - dashboard
+ - notion
+ - github
+ created: "2026-02-20"
+ modified: "2026-02-20"
+ stakeholders:
+ - role: "owner"
+ fullName: "Jane Doe"
+ email: "jane.doe@example.com"
+ - role: "editor"
+ fullName: "John Smith"
+ email: "john.smith@example.com"
+
+capability:
+ exposes:
+ - type: "api"
+ port: 9090
+ namespace: "dashboard"
+ resources:
+ # --- Forward proxy (simplest) ---
+ - path: "/github/{path}"
+ description: "Direct pass-through to the GitHub API for debugging"
+ forward:
+ targetNamespace: "github"
+ trustedHeaders:
+ - "Authorization"
+
+ # --- Simple mode (direct call) ---
+ - path: "/repos/{owner}/{repo}"
+ description: "Retrieve a GitHub repository summary"
+ name: "repo"
+ inputParameters:
+ - name: "owner"
+ in: "path"
+ type: "string"
+ description: "Repository owner (user or organization)"
+ - name: "repo"
+ in: "path"
+ type: "string"
+ description: "Repository name"
+ operations:
+ - method: "GET"
+ label: "Get Repository"
+ call: "github.get-repo"
+ with:
+ owner: "$this.dashboard.owner"
+ repo: "$this.dashboard.repo"
+ outputParameters:
+ - type: "string"
+ mapping: "$.full_name"
+ - type: "number"
+ mapping: "$.stargazers_count"
+ - type: "string"
+ mapping: "$.language"
+
+ # --- Orchestrated mode (multi-step call + lookup) ---
+ - path: "/projects/{database_id}/contributors"
+ description: "Lists project tasks from Notion and enriches each assignee with GitHub profile data"
+ name: "contributors"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ type: "string"
+ description: "Notion database ID for the project tracker"
+ operations:
+ - name: "list-contributors"
+ method: "GET"
+ label: "List Project Contributors"
+ steps:
+ - type: "call"
+ name: "query-tasks"
+ call: "notion.query-database"
+ with:
+ database_id: "$this.dashboard.database_id"
+ - type: "call"
+ name: "list-github-users"
+ call: "github.list-org-members"
+ with:
+ org: "naftiko"
+ - type: "lookup"
+ name: "match-contributors"
+ index: "list-github-users"
+ match: "login"
+ lookupValue: "$.query-tasks.assignee"
+ outputParameters:
+ - "login"
+ - "avatar_url"
+ - "html_url"
+ mappings:
+ - targetName: "contributors"
+ value: "$.match-contributors"
+ outputParameters:
+ - name: "contributors"
+ type: "array"
+ items:
+ type: "object"
+ properties:
+ login:
+ type: "string"
+ avatar_url:
+ type: "string"
+ html_url:
+ type: "string"
+
+ consumes:
+ - type: "http"
+ namespace: "notion"
+ description: "Notion public API for database and page operations"
+ baseUri: "https://api.notion.com/v1"
+ authentication:
+ type: "bearer"
+ token: "{{notion_token}}"
+ inputParameters:
+ - name: "Notion-Version"
+ in: "header"
+ value: "2022-06-28"
+ resources:
+ - name: "db-query"
+ path: "/databases/{database_id}/query"
+ label: "Database Query"
+ operations:
+ - name: "query-database"
+ label: "Query Database"
+ method: "POST"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ outputParameters:
+ - name: "assignee"
+ type: "string"
+ value: "$.results[*].properties.Assignee.people[0].name"
+ - name: "taskName"
+ type: "string"
+ value: "$.results[*].properties.Name.title[0].text.content"
+
+ - type: "http"
+ namespace: "github"
+ description: "GitHub REST API for repository and user operations"
+ baseUri: "https://api.github.com"
+ authentication:
+ type: "bearer"
+ token: "{{github_token}}"
+ resources:
+ - name: "repos"
+ path: "/repos/{owner}/{repo}"
+ label: "Repositories"
+ operations:
+ - name: "get-repo"
+ label: "Get Repository"
+ method: "GET"
+ inputParameters:
+ - name: "owner"
+ in: "path"
+ - name: "repo"
+ in: "path"
+ outputParameters:
+ - name: "full_name"
+ type: "string"
+ value: "$.full_name"
+ - name: "stargazers_count"
+ type: "number"
+ value: "$.stargazers_count"
+ - name: "language"
+ type: "string"
+ value: "$.language"
+ - name: "org-members"
+ path: "/orgs/{org}/members"
+ label: "Organization Members"
+ operations:
+ - name: "list-org-members"
+ label: "List Organization Members"
+ method: "GET"
+ inputParameters:
+ - name: "org"
+ in: "path"
+ outputParameters:
+ - name: "login"
+ type: "string"
+ value: "$[*].login"
+ - name: "avatar_url"
+ type: "string"
+ value: "$[*].avatar_url"
+ - name: "html_url"
+ type: "string"
+ value: "$[*].html_url"
+```
+
+---
+
+### 4.6 MCP capability (tool exposition)
+
+Exposes a single MCP tool over Streamable HTTP that calls a consumed operation, making it discoverable by AI agents via the MCP protocol.
+
+```yaml
+---
+naftiko: "0.5"
+externalRefs:
+ - name: "env"
+ type: "environment"
+ keys:
+ notion_token: "NOTION_TOKEN"
+info:
+ label: "Notion MCP Tools"
+ description: "Exposes Notion database retrieval as an MCP tool"
+
+capability:
+ exposes:
+ - type: "mcp"
+ port: 3001
+ namespace: "notion-tools"
+ description: "Notion database tools for AI agents"
+ tools:
+ - name: "get-database"
+ description: "Retrieve metadata about a Notion database by its ID"
+ inputParameters:
+ - name: "database_id"
+ type: "string"
+ description: "The unique identifier of the Notion database"
+ call: "notion.get-database"
+ with:
+ database_id: "$this.notion-tools.database_id"
+ outputParameters:
+ - type: "string"
+ mapping: "$.dbName"
+
+ consumes:
+ - type: "http"
+ namespace: "notion"
+ description: "Notion public API"
+ baseUri: "https://api.notion.com/v1"
+ authentication:
+ type: "bearer"
+ token: "{{notion_token}}"
+ inputParameters:
+ - name: "Notion-Version"
+ in: "header"
+ value: "2022-06-28"
+ resources:
+ - name: "databases"
+ path: "/databases/{database_id}"
+ operations:
+ - name: "get-database"
+ method: "GET"
+ inputParameters:
+ - name: "database_id"
+ in: "path"
+ outputParameters:
+ - name: "dbName"
+ type: "string"
+ value: "$.title[0].text.content"
+```
+
+---
+
+## 5. Versioning
+
+The Naftiko Specification uses semantic versioning. The `naftiko` field in the Naftiko Object specifies the exact version of the specification (e.g., `"0.5"`).
+
+Tools processing Naftiko documents MUST validate this field to ensure compatibility with the specification version they support.
+
+---
+
+This specification defines how to describe modular, composable capabilities that consume multiple sources and expose unified interfaces, supporting orchestration, authentication, and flexible routing patterns.
\ No newline at end of file
diff --git a/src/test/java/io/naftiko/engine/CapabilityMcpResourcesPromptsIntegrationTest.java b/src/test/java/io/naftiko/engine/CapabilityMcpResourcesPromptsIntegrationTest.java
new file mode 100644
index 0000000..8299c5e
--- /dev/null
+++ b/src/test/java/io/naftiko/engine/CapabilityMcpResourcesPromptsIntegrationTest.java
@@ -0,0 +1,589 @@
+/**
+ * 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 com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.naftiko.Capability;
+import io.naftiko.engine.exposes.McpPromptHandler;
+import io.naftiko.engine.exposes.McpResourceHandler;
+import io.naftiko.engine.exposes.McpServerAdapter;
+import io.naftiko.spec.NaftikoSpec;
+import io.naftiko.spec.exposes.McpServerPromptSpec;
+import io.naftiko.spec.exposes.McpServerResourceSpec;
+import io.naftiko.spec.exposes.McpServerSpec;
+import io.naftiko.spec.exposes.McpServerToolSpec;
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Integration tests for MCP Server Adapter with resources and prompts support.
+ * Tests YAML deserialization, spec wiring, handler creation, and protocol dispatch.
+ */
+public class CapabilityMcpResourcesPromptsIntegrationTest {
+
+ private Capability capability;
+ private McpServerAdapter adapter;
+ private ObjectMapper jsonMapper;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ String resourcePath = "src/test/resources/mcp-resources-prompts-capability.yaml";
+ File file = new File(resourcePath);
+
+ assertTrue(file.exists(),
+ "MCP resources/prompts capability test file should exist at " + resourcePath);
+
+ ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
+ yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ NaftikoSpec spec = yamlMapper.readValue(file, NaftikoSpec.class);
+
+ capability = new Capability(spec);
+ adapter = (McpServerAdapter) capability.getServerAdapters().get(0);
+ jsonMapper = new ObjectMapper();
+ }
+
+ // ── Capability loading ────────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testCapabilityLoaded() {
+ assertNotNull(capability, "Capability should be initialized");
+ assertEquals("0.5", capability.getSpec().getNaftiko(), "Naftiko version should be 0.5");
+ }
+
+ @Test
+ public void testMcpServerAdapterCreated() {
+ assertNotNull(adapter, "McpServerAdapter should be created");
+ }
+
+ // ── Spec deserialization ──────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testResourceSpecsDeserialized() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ List resources = spec.getResources();
+
+ assertEquals(2, resources.size(), "Should have exactly 2 resource specs");
+ }
+
+ @Test
+ public void testStaticLikeResourceSpecFields() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ McpServerResourceSpec dbSchema = spec.getResources().stream()
+ .filter(r -> "database-schema".equals(r.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("database-schema resource not found"));
+
+ assertEquals("database-schema", dbSchema.getName());
+ assertEquals("Database Schema", dbSchema.getLabel());
+ assertEquals("data://databases/pre-release/schema", dbSchema.getUri());
+ assertTrue(dbSchema.getDescription().contains("pre-release participants database"));
+ assertEquals("application/json", dbSchema.getMimeType());
+ assertNotNull(dbSchema.getCall(), "Should have a call spec");
+ assertEquals("mock-api.query-db", dbSchema.getCall().getOperation());
+ assertFalse(dbSchema.isStatic(), "Dynamic resource should not be static");
+ assertFalse(dbSchema.isTemplate(), "Non-template URI should not be a template");
+ }
+
+ @Test
+ public void testTemplateResourceSpecFields() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ McpServerResourceSpec userProfile = spec.getResources().stream()
+ .filter(r -> "user-profile".equals(r.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("user-profile resource not found"));
+
+ assertEquals("User Profile", userProfile.getLabel());
+ assertEquals("data://users/{userId}/profile", userProfile.getUri());
+ assertTrue(userProfile.isTemplate(), "URI with {param} should be a template");
+ assertFalse(userProfile.isStatic(), "Dynamic resource should not be static");
+ }
+
+ @Test
+ public void testPromptSpecsDeserialized() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ List prompts = spec.getPrompts();
+
+ assertEquals(2, prompts.size(), "Should have exactly 2 prompt specs");
+ }
+
+ @Test
+ public void testParticipantOutreachPromptFields() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ McpServerPromptSpec outreach = spec.getPrompts().stream()
+ .filter(p -> "participant-outreach".equals(p.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("participant-outreach prompt not found"));
+
+ assertEquals("Participant Outreach", outreach.getLabel());
+ assertTrue(outreach.getDescription().contains("pre-release participants"));
+ assertFalse(outreach.isFileBased(), "Inline prompt should not be file-based");
+ assertEquals(2, outreach.getArguments().size(), "Should have 2 arguments");
+ assertEquals(1, outreach.getTemplate().size(), "Should have 1 template message");
+
+ var participantArg = outreach.getArguments().stream()
+ .filter(a -> "participant_name".equals(a.getName()))
+ .findFirst()
+ .orElseThrow();
+ assertTrue(participantArg.isRequired(), "participant_name should be required");
+ }
+
+ @Test
+ public void testSummaryPromptMultiTurnTemplate() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ McpServerPromptSpec summary = spec.getPrompts().stream()
+ .filter(p -> "summary-prompt".equals(p.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("summary-prompt not found"));
+
+ assertEquals(3, summary.getTemplate().size(), "Should have 3 template messages");
+
+ // Verify role sequence: user, assistant, user
+ assertEquals("user", summary.getTemplate().get(0).getRole());
+ assertEquals("assistant", summary.getTemplate().get(1).getRole());
+ assertEquals("user", summary.getTemplate().get(2).getRole());
+
+ // format argument is optional
+ var formatArg = summary.getArguments().stream()
+ .filter(a -> "format".equals(a.getName()))
+ .findFirst()
+ .orElseThrow();
+ assertFalse(formatArg.isRequired(), "format argument should be optional");
+ }
+
+ @Test
+ public void testToolLabelDeserialized() {
+ McpServerSpec spec = adapter.getMcpServerSpec();
+ McpServerToolSpec tool = spec.getTools().get(0);
+
+ assertEquals("Query Database", tool.getLabel(), "Tool label should be deserialized");
+ }
+
+ // ── Handler wiring ────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testResourceHandlerCreated() {
+ assertNotNull(adapter.getResourceHandler(), "McpResourceHandler should be created");
+ }
+
+ @Test
+ public void testPromptHandlerCreated() {
+ assertNotNull(adapter.getPromptHandler(), "McpPromptHandler should be created");
+ }
+
+ @Test
+ public void testToolLabelsMap() {
+ Map labels = adapter.getToolLabels();
+ assertNotNull(labels, "Tool labels map should not be null");
+ assertEquals("Query Database", labels.get("query-database"),
+ "Tool label should be in the labels map");
+ }
+
+ // ── MCP protocol: initialize ──────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testInitializeAdvertisesResourcesAndPrompts() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ assertNotNull(response, "initialize should return a response");
+ JsonNode result = response.path("result");
+
+ JsonNode capabilities = result.path("capabilities");
+ assertFalse(capabilities.isMissingNode(), "capabilities should be present");
+ assertFalse(capabilities.path("resources").isMissingNode(),
+ "resources capability should be advertised when resources are declared");
+ assertFalse(capabilities.path("prompts").isMissingNode(),
+ "prompts capability should be advertised when prompts are declared");
+ }
+
+ // ── MCP protocol: tools/list ──────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testToolsListIncludesTitle() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ JsonNode tools = response.path("result").path("tools");
+ assertFalse(tools.isMissingNode(), "tools array should be present");
+ assertTrue(tools.isArray() && tools.size() > 0, "Should have at least one tool");
+
+ JsonNode tool = tools.get(0);
+ assertEquals("query-database", tool.path("name").asText());
+ assertEquals("Query Database", tool.path("title").asText(),
+ "title field should be present from label");
+ }
+
+ // ── MCP protocol: resources/list ─────────────────────────────────────────────────────────────
+
+ @Test
+ public void testResourcesListReturnsConcrete() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ JsonNode resources = response.path("result").path("resources");
+ assertFalse(resources.isMissingNode(), "resources array should be present");
+
+ // database-schema (non-template) should be in the list
+ boolean found = false;
+ for (JsonNode r : resources) {
+ if ("data://databases/pre-release/schema".equals(r.path("uri").asText())) {
+ found = true;
+ assertEquals("database-schema", r.path("name").asText());
+ assertEquals("Database Schema", r.path("title").asText());
+ assertEquals("application/json", r.path("mimeType").asText());
+ }
+ }
+ assertTrue(found, "database-schema resource should appear in resources/list");
+ }
+
+ @Test
+ public void testResourcesListExcludesTemplates() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":4,"method":"resources/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ JsonNode resources = response.path("result").path("resources");
+ for (JsonNode r : resources) {
+ assertFalse(r.path("uri").asText().contains("{"),
+ "resources/list should not include template URIs");
+ }
+ }
+
+ // ── MCP protocol: resources/templates/list ───────────────────────────────────────────────────
+
+ @Test
+ public void testResourcesTemplatesList() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":5,"method":"resources/templates/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ JsonNode templates = response.path("result").path("resourceTemplates");
+ assertFalse(templates.isMissingNode(), "resourceTemplates should be present");
+ assertTrue(templates.isArray() && templates.size() > 0,
+ "Should have at least one resource template");
+
+ JsonNode tmpl = templates.get(0);
+ assertEquals("data://users/{userId}/profile", tmpl.path("uriTemplate").asText(),
+ "uriTemplate should carry the raw URI with {param}");
+ assertEquals("user-profile", tmpl.path("name").asText());
+ assertEquals("User Profile", tmpl.path("title").asText());
+ }
+
+ // ── MCP protocol: prompts/list ────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testPromptsListReturnsBothPrompts() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":6,"method":"prompts/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ JsonNode prompts = response.path("result").path("prompts");
+ assertFalse(prompts.isMissingNode(), "prompts array should be present");
+ assertEquals(2, prompts.size(), "Should return exactly 2 prompts");
+ }
+
+ @Test
+ public void testPromptsListIncludesTitleAndArguments() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":7,"method":"prompts/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ JsonNode prompts = response.path("result").path("prompts");
+ JsonNode outreach = null;
+ for (JsonNode p : prompts) {
+ if ("participant-outreach".equals(p.path("name").asText())) {
+ outreach = p;
+ }
+ }
+ assertNotNull(outreach, "participant-outreach should be in prompts list");
+ assertEquals("Participant Outreach", outreach.path("title").asText());
+
+ JsonNode args = outreach.path("arguments");
+ assertTrue(args.isArray() && args.size() == 2, "Should have 2 arguments");
+
+ boolean foundParticipant = false;
+ for (JsonNode arg : args) {
+ if ("participant_name".equals(arg.path("name").asText())) {
+ foundParticipant = true;
+ assertTrue(arg.path("required").asBoolean(), "participant_name should be required");
+ }
+ }
+ assertTrue(foundParticipant, "participant_name argument should be listed");
+ }
+
+ @Test
+ public void testPromptsListArgumentTitleEmitted() throws Exception {
+ // PromptArgument.title is required by the 2025-11-25 schema when a label is declared.
+ // Our YAML fixture declares arguments without an explicit 'label', so title should be
+ // absent. This test verifies presence/absence matches the label declaration.
+ String requestJson = """
+ {"jsonrpc":"2.0","id":99,"method":"prompts/list","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+ JsonNode prompts = response.path("result").path("prompts");
+
+ // The YAML arguments have no 'label' field declared, so 'title' must NOT appear.
+ for (JsonNode p : prompts) {
+ for (JsonNode arg : p.path("arguments")) {
+ assertTrue(arg.path("title").isMissingNode(),
+ "title should be absent for arguments that have no label declared");
+ }
+ }
+ }
+
+ // ── MCP protocol: prompts/get ─────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testPromptsGetRendersInlineTemplate() throws Exception {
+ String requestJson = """
+ {
+ "jsonrpc": "2.0",
+ "id": 8,
+ "method": "prompts/get",
+ "params": {
+ "name": "participant-outreach",
+ "arguments": {
+ "participant_name": "Alice",
+ "product_name": "Naftiko"
+ }
+ }
+ }
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ assertNull(response.get("error"), "prompts/get should not return an error");
+ JsonNode messages = response.path("result").path("messages");
+ assertTrue(messages.isArray() && messages.size() == 1, "Should have 1 rendered message");
+
+ JsonNode msg = messages.get(0);
+ assertEquals("user", msg.path("role").asText());
+
+ JsonNode content = msg.path("content");
+ String text = content.path("text").asText();
+ assertTrue(text.contains("Alice"), "Rendered message should contain participant_name");
+ assertTrue(text.contains("Naftiko"), "Rendered message should contain product_name");
+ assertFalse(text.contains("{{"), "No unrendered placeholders should remain");
+ }
+
+ @Test
+ public void testPromptsGetMultiTurnTemplate() throws Exception {
+ String requestJson = """
+ {
+ "jsonrpc": "2.0",
+ "id": 9,
+ "method": "prompts/get",
+ "params": {
+ "name": "summary-prompt",
+ "arguments": {
+ "data": "item1, item2, item3",
+ "format": "bullet-points"
+ }
+ }
+ }
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ assertNull(response.get("error"), "prompts/get should not return an error");
+ JsonNode messages = response.path("result").path("messages");
+ assertEquals(3, messages.size(), "Should have 3 rendered messages");
+
+ assertEquals("user", messages.get(0).path("role").asText());
+ assertEquals("assistant", messages.get(1).path("role").asText());
+ assertEquals("user", messages.get(2).path("role").asText());
+
+ String firstText = messages.get(0).path("content").path("text").asText();
+ assertTrue(firstText.contains("bullet-points"), "format placeholder should be substituted");
+ assertTrue(firstText.contains("item1, item2, item3"), "data placeholder should be substituted");
+ }
+
+ @Test
+ public void testPromptsGetMissingArgumentLeavesPlaceholder() throws Exception {
+ // Omit format — optional arg; placeholder should be left if not provided
+ String requestJson = """
+ {
+ "jsonrpc": "2.0",
+ "id": 10,
+ "method": "prompts/get",
+ "params": {
+ "name": "summary-prompt",
+ "arguments": {
+ "data": "some data"
+ }
+ }
+ }
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ assertNull(response.get("error"), "Missing optional arg should not cause an error");
+ JsonNode messages = response.path("result").path("messages");
+ String firstText = messages.get(0).path("content").path("text").asText();
+ // {{format}} should remain since it was not provided
+ assertTrue(firstText.contains("{{format}}"),
+ "Unresolved optional placeholder should remain as-is");
+ }
+
+ @Test
+ public void testPromptsGetUnknownPromptReturnsError() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":11,"method":"prompts/get","params":{"name":"does-not-exist"}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+
+ assertNotNull(response.path("error"), "Unknown prompt should return JSON-RPC error");
+ assertFalse(response.path("error").isMissingNode(),
+ "Error field should be present for unknown prompt");
+ }
+
+ // ── McpResourceHandler unit tests ─────────────────────────────────────────────────────────────
+
+ @Test
+ public void testMatchTemplateExactNonTemplate() {
+ Map result = McpResourceHandler.matchTemplate(
+ "data://databases/schema", "data://databases/schema");
+ assertNotNull(result, "Exact match should succeed");
+ assertTrue(result.isEmpty(), "No params for a non-template URI");
+ }
+
+ @Test
+ public void testMatchTemplateNoMatch() {
+ Map result = McpResourceHandler.matchTemplate(
+ "data://databases/schema", "data://databases/other");
+ assertNull(result, "Different URI should not match");
+ }
+
+ @Test
+ public void testMatchTemplateExtracts() {
+ Map result = McpResourceHandler.matchTemplate(
+ "data://users/{userId}/profile", "data://users/42/profile");
+ assertNotNull(result, "Template match should succeed");
+ assertEquals("42", result.get("userId"), "userId param should be extracted");
+ }
+
+ @Test
+ public void testMatchTemplateMultipleParams() {
+ Map result = McpResourceHandler.matchTemplate(
+ "data://orgs/{orgId}/users/{userId}", "data://orgs/acme/users/bob");
+ assertNotNull(result, "Multi-param match should succeed");
+ assertEquals("acme", result.get("orgId"));
+ assertEquals("bob", result.get("userId"));
+ }
+
+ @Test
+ public void testMatchTemplateDoesNotCrossSegmentBoundary() {
+ // {userId} should not match across '/'
+ Map result = McpResourceHandler.matchTemplate(
+ "data://users/{userId}/profile", "data://users/a/b/profile");
+ assertNull(result, "{param} should not span path separators");
+ }
+
+ // ── McpPromptHandler unit tests ───────────────────────────────────────────────────────────────
+
+ @Test
+ public void testSubstituteBasic() {
+ Map args = Map.of("name", "Alice", "product", "Naftiko");
+ String result = McpPromptHandler.substitute("Hello {{name}}, welcome to {{product}}!", args);
+ assertEquals("Hello Alice, welcome to Naftiko!", result);
+ }
+
+ @Test
+ public void testSubstituteUnknownPlaceholderUnchanged() {
+ Map args = Map.of("name", "Alice");
+ String result = McpPromptHandler.substitute("Hello {{name}} from {{team}}!", args);
+ assertEquals("Hello Alice from {{team}}!", result,
+ "Unresolved placeholder should be left as-is");
+ }
+
+ @Test
+ public void testSubstituteInjectionPrevention() {
+ // An attacker-controlled value that itself looks like a placeholder
+ Map args = Map.of("greeting", "Hi {{admin}}");
+ String result = McpPromptHandler.substitute("{{greeting}} there", args);
+ // The value should appear literally — not be re-interpolated
+ assertEquals("Hi {{admin}} there", result,
+ "Placeholder syntax inside argument values must not be re-interpolated");
+ }
+
+ @Test
+ public void testSubstituteNullTemplate() {
+ String result = McpPromptHandler.substitute(null, Map.of("x", "y"));
+ assertEquals("", result, "null template should produce empty string");
+ }
+
+ @Test
+ public void testSubstituteBackslashAndDollarSafe() {
+ // Values containing regex special chars ($ and \) should be treated as literals
+ Map args = Map.of("value", "C:\\Users\\$HOME");
+ String result = McpPromptHandler.substitute("Path: {{value}}", args);
+ assertEquals("Path: C:\\Users\\$HOME", result,
+ "Backslash and dollar sign in argument values must be literal");
+ }
+
+ // ── Protocol error handling ───────────────────────────────────────────────────────────────────
+
+ @Test
+ public void testResourcesReadMissingUriReturnsError() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":12,"method":"resources/read","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+ assertFalse(response.path("error").isMissingNode(),
+ "Missing uri should return JSON-RPC error");
+ }
+
+ @Test
+ public void testResourcesReadUnknownUriReturnsError() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":13,"method":"resources/read","params":{"uri":"data://does-not-exist"}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+ assertFalse(response.path("error").isMissingNode(),
+ "Unknown URI should return JSON-RPC error");
+ }
+
+ @Test
+ public void testPromptsGetMissingNameReturnsError() throws Exception {
+ String requestJson = """
+ {"jsonrpc":"2.0","id":14,"method":"prompts/get","params":{}}
+ """;
+ ObjectNode response = dispatch(requestJson);
+ assertFalse(response.path("error").isMissingNode(),
+ "Missing name should return JSON-RPC error");
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────────────────────────
+
+ private ObjectNode dispatch(String requestJson) throws Exception {
+ JsonNode request = jsonMapper.readTree(requestJson);
+ var dispatcher = new io.naftiko.engine.exposes.McpProtocolDispatcher(adapter);
+ return dispatcher.dispatch(request);
+ }
+}
diff --git a/src/test/java/io/naftiko/engine/CapabilityMcpStdioIntegrationTest.java b/src/test/java/io/naftiko/engine/CapabilityMcpStdioIntegrationTest.java
index 23b6133..38d2d11 100644
--- a/src/test/java/io/naftiko/engine/CapabilityMcpStdioIntegrationTest.java
+++ b/src/test/java/io/naftiko/engine/CapabilityMcpStdioIntegrationTest.java
@@ -104,7 +104,7 @@ public void testStdioInitializeProtocol() throws Exception {
ObjectMapper mapper = new ObjectMapper();
String initRequest = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
- + "\"params\":{\"protocolVersion\":\"2025-03-26\","
+ + "\"params\":{\"protocolVersion\":\"2025-11-25\","
+ "\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"},"
+ "\"capabilities\":{}}}";
@@ -117,7 +117,7 @@ public void testStdioInitializeProtocol() throws Exception {
JsonNode result = response.get("result");
assertNotNull(result, "Should have a result");
- assertEquals("2025-03-26", result.path("protocolVersion").asText());
+ assertEquals("2025-11-25", result.path("protocolVersion").asText());
assertEquals("test-mcp-stdio",
result.path("serverInfo").path("name").asText());
}
@@ -177,7 +177,7 @@ public void testStdioHandlerEndToEnd() throws Exception {
// Build a multi-line input: initialize + tools/list + ping
String input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
- + "\"params\":{\"protocolVersion\":\"2025-03-26\","
+ + "\"params\":{\"protocolVersion\":\"2025-11-25\","
+ "\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"},"
+ "\"capabilities\":{}}}\n"
+ "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n"
@@ -203,7 +203,7 @@ public void testStdioHandlerEndToEnd() throws Exception {
// Verify initialize response
JsonNode initResponse = mapper.readTree(lines[0]);
assertEquals(1, initResponse.path("id").asInt());
- assertEquals("2025-03-26",
+ assertEquals("2025-11-25",
initResponse.path("result").path("protocolVersion").asText());
// Verify tools/list response
diff --git a/src/test/resources/mcp-resources-prompts-capability.yaml b/src/test/resources/mcp-resources-prompts-capability.yaml
new file mode 100644
index 0000000..b4eef57
--- /dev/null
+++ b/src/test/resources/mcp-resources-prompts-capability.yaml
@@ -0,0 +1,108 @@
+---
+naftiko: "0.5"
+info:
+ label: "MCP Resources & Prompts Test Capability"
+ description: "Test capability for MCP Server Adapter resources and prompts support"
+ tags:
+ - Test
+ - MCP
+ created: "2026-03-06"
+ modified: "2026-03-06"
+
+capability:
+ exposes:
+ - type: "mcp"
+ address: "localhost"
+ port: 9096
+ namespace: "test-mcp-resources-prompts"
+ description: "Test MCP server for validating resources and prompts support."
+
+ tools:
+ - name: "query-database"
+ label: "Query Database"
+ description: "Query the test database to retrieve committed participants."
+ call: "mock-api.query-db"
+ with:
+ datasource_id: "test-db-id-123"
+ outputParameters:
+ - type: "array"
+ mapping: "$.results"
+
+ resources:
+ - name: "database-schema"
+ label: "Database Schema"
+ uri: "data://databases/pre-release/schema"
+ description: "Schema of the pre-release participants database"
+ mimeType: "application/json"
+ call: "mock-api.query-db"
+ with:
+ datasource_id: "test-db-id-123"
+
+ - name: "user-profile"
+ label: "User Profile"
+ uri: "data://users/{userId}/profile"
+ description: "User profile by ID"
+ mimeType: "application/json"
+ call: "mock-api.query-db"
+ with:
+ datasource_id: "{{userId}}"
+
+ prompts:
+ - name: "participant-outreach"
+ label: "Participant Outreach"
+ description: "Draft outreach message to pre-release participants"
+ arguments:
+ - name: "participant_name"
+ description: "Name of the participant"
+ required: true
+ - name: "product_name"
+ description: "Name of the product"
+ required: true
+ template:
+ - role: "user"
+ content: "Draft a personalized outreach email to {{participant_name}} about the upcoming {{product_name}} pre-release. Be professional but friendly."
+
+ - name: "summary-prompt"
+ label: "Summary Prompt"
+ description: "Summarize data in a given format"
+ arguments:
+ - name: "data"
+ description: "The raw data to summarize"
+ required: true
+ - name: "format"
+ description: "Output format: bullet-points, paragraph, or table"
+ required: false
+ template:
+ - role: "user"
+ content: "Summarize the following data in {{format}} format:\n\n{{data}}"
+ - role: "assistant"
+ content: "I'll summarize the data for you."
+ - role: "user"
+ content: "Please focus on the key insights."
+
+ consumes:
+ - type: "http"
+ namespace: "mock-api"
+ baseUri: "http://localhost:8080/v1/"
+ inputParameters:
+ - name: "Content-Type"
+ in: "header"
+ value: "application/json"
+
+ resources:
+ - path: "databases/{{datasource_id}}/query"
+ name: "query"
+ label: "Query database resource"
+ operations:
+ - method: "POST"
+ name: "query-db"
+ label: "Query Database"
+ body: |
+ {
+ "filter": {
+ "property": "Status",
+ "select": {
+ "equals": "Committed"
+ }
+ }
+ }