From 1fc57df499559beaac01181acfb2ba2930eddc61 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:10:34 -0500 Subject: [PATCH 1/2] feat: Add MCP Resources and Prompts support Introduce McpServerResourceSpec and McpServerPromptSpec to extend MCP server capabilities with data resources and reusable prompt templates. Add corresponding handlers, refactor OperationStepExecutor for reusability, update schema to v0.5, and include comprehensive tests and specification documentation. --- .../engine/exposes/McpPromptHandler.java | 162 ++ .../engine/exposes/McpProtocolDispatcher.java | 214 +- .../engine/exposes/McpResourceHandler.java | 327 +++ .../engine/exposes/McpServerAdapter.java | 25 + .../engine/exposes/McpToolHandler.java | 82 +- .../engine/exposes/OperationStepExecutor.java | 65 + .../spec/exposes/McpPromptArgumentSpec.java | 77 + .../spec/exposes/McpPromptMessageSpec.java | 50 + .../spec/exposes/McpServerPromptSpec.java | 101 + .../spec/exposes/McpServerResourceSpec.java | 153 ++ .../naftiko/spec/exposes/McpServerSpec.java | 16 + .../spec/exposes/McpServerToolSpec.java | 16 +- .../resources/schemas/capability-schema.json | 226 +- .../specs/naftiko-specification-v0.5.md | 2335 +++++++++++++++++ ...ityMcpResourcesPromptsIntegrationTest.java | 589 +++++ .../CapabilityMcpStdioIntegrationTest.java | 8 +- .../mcp-resources-prompts-capability.yaml | 108 + 17 files changed, 4477 insertions(+), 77 deletions(-) create mode 100644 src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java create mode 100644 src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java create mode 100644 src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java create mode 100644 src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java create mode 100644 src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java create mode 100644 src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java create mode 100644 src/main/resources/specs/naftiko-specification-v0.5.md create mode 100644 src/test/java/io/naftiko/engine/CapabilityMcpResourcesPromptsIntegrationTest.java create mode 100644 src/test/resources/mcp-resources-prompts-capability.yaml 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..0ececf9 --- /dev/null +++ b/src/main/resources/specs/naftiko-specification-v0.5.md @@ -0,0 +1,2335 @@ +# 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. + + + +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. + + + +#### 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" + } + } + } From 18789aac9d19e693dbf10ef08d4bc29f93064e82 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:36:00 -0400 Subject: [PATCH 2/2] Fixed table layout --- .../specs/naftiko-specification-v0.5.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/main/resources/specs/naftiko-specification-v0.5.md b/src/main/resources/specs/naftiko-specification-v0.5.md index 0ececf9..1362a4e 100644 --- a/src/main/resources/specs/naftiko-specification-v0.5.md +++ b/src/main/resources/specs/naftiko-specification-v0.5.md @@ -68,21 +68,7 @@ Three specifications that work better together. | --- | --- | --- | --- | --- | | **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 | +| **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 |