diff --git a/src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java b/src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java new file mode 100644 index 0000000..06eb166 --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/McpPromptHandler.java @@ -0,0 +1,162 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import io.naftiko.spec.exposes.McpPromptMessageSpec; +import io.naftiko.spec.exposes.McpServerPromptSpec; + +/** + * Handles MCP {@code prompts/get} requests by rendering prompt templates. + * + *

Inline prompts substitute {@code {{arg}}} placeholders in the declared message list. + * Argument values are treated as literal text — nested {@code {{...}}} in values are NOT + * re-interpolated (prevents prompt injection).

+ * + *

File-based prompts load the file at the {@code location} URI, substitute arguments, + * and return the content as a single {@code user} role message.

+ */ +public class McpPromptHandler { + + /** Matches {{argName}} placeholders — arg names are alphanumeric + underscore. */ + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{([a-zA-Z0-9_]+)\\}\\}"); + + private final Map promptSpecs; + + public McpPromptHandler(List prompts) { + this.promptSpecs = new ConcurrentHashMap<>(); + for (McpServerPromptSpec prompt : prompts) { + promptSpecs.put(prompt.getName(), prompt); + } + } + + /** + * A rendered prompt message returned by {@link #render(String, Map)}. + */ + public static class RenderedMessage { + public final String role; + public final String text; + + public RenderedMessage(String role, String text) { + this.role = role; + this.text = text; + } + } + + /** + * Render a prompt by name with the provided arguments. + * + * @param name the prompt name + * @param arguments key-value argument map (values are always treated as strings) + * @return ordered list of rendered messages + * @throws IllegalArgumentException when the prompt is unknown + * @throws IOException when a file-based prompt cannot be read + */ + public List render(String name, Map arguments) + throws IOException { + + McpServerPromptSpec spec = promptSpecs.get(name); + if (spec == null) { + throw new IllegalArgumentException("Unknown prompt: " + name); + } + + Map args = toStringMap(arguments); + + if (spec.isFileBased()) { + return renderFileBased(spec, args); + } else { + return renderInline(spec, args); + } + } + + /** + * Return all prompt specs (for {@code prompts/list}). + */ + public List listAll() { + return new ArrayList<>(promptSpecs.values()); + } + + // ── Inline rendering ───────────────────────────────────────────────────────────────────────── + + private List renderInline(McpServerPromptSpec spec, + Map args) { + List messages = new ArrayList<>(); + for (McpPromptMessageSpec msg : spec.getTemplate()) { + String rendered = substitute(msg.getContent(), args); + messages.add(new RenderedMessage(msg.getRole(), rendered)); + } + return messages; + } + + // ── File-based rendering ───────────────────────────────────────────────────────────────────── + + private List renderFileBased(McpServerPromptSpec spec, + Map args) throws IOException { + Path file = Paths.get(URI.create(spec.getLocation())); + if (!Files.isRegularFile(file)) { + throw new IOException( + "Prompt file not found for '" + spec.getName() + "': " + spec.getLocation()); + } + String content = Files.readString(file, StandardCharsets.UTF_8); + String rendered = substitute(content, args); + return List.of(new RenderedMessage("user", rendered)); + } + + // ── Argument substitution ──────────────────────────────────────────────────────────────────── + + /** + * Replace all {@code {{argName}}} occurrences in {@code template} with the corresponding value + * from {@code args}. Unrecognised placeholders are left as-is. Argument values are escaped so + * that any {@code {{...}}} sequences they contain are NOT treated as additional placeholders + * (prevents prompt injection). + */ + public static String substitute(String template, Map args) { + if (template == null) { + return ""; + } + Matcher m = PLACEHOLDER.matcher(template); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + String argName = m.group(1); + String value = args.getOrDefault(argName, m.group(0)); // leave placeholder if missing + // Escape literal backslashes and dollar signs for Matcher.appendReplacement + m.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + m.appendTail(sb); + return sb.toString(); + } + + private static Map toStringMap(Map arguments) { + Map result = new ConcurrentHashMap<>(); + if (arguments != null) { + for (Map.Entry entry : arguments.entrySet()) { + if (entry.getValue() != null) { + result.put(entry.getKey(), String.valueOf(entry.getValue())); + } + } + } + return result; + } +} diff --git a/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java b/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java index da6ab1f..dfd176e 100644 --- a/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java +++ b/src/main/java/io/naftiko/engine/exposes/McpProtocolDispatcher.java @@ -13,6 +13,7 @@ */ package io.naftiko.engine.exposes; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; @@ -22,18 +23,22 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.modelcontextprotocol.spec.McpSchema; +import io.naftiko.spec.exposes.McpPromptArgumentSpec; +import io.naftiko.spec.exposes.McpServerPromptSpec; +import io.naftiko.spec.exposes.McpServerResourceSpec; /** * Transport-agnostic MCP JSON-RPC protocol dispatcher. * - * Handles MCP protocol methods (initialize, tools/list, tools/call, ping) + * Handles MCP protocol methods (initialize, tools/list, tools/call, resources/list, + * resources/read, resources/templates/list, prompts/list, prompts/get, ping) * and produces JSON-RPC response envelopes. Used by both the Streamable HTTP * handler and the stdio handler. */ public class McpProtocolDispatcher { static final String JSONRPC_VERSION = "2.0"; - static final String MCP_PROTOCOL_VERSION = "2025-03-26"; + static final String MCP_PROTOCOL_VERSION = "2025-11-25"; private final McpServerAdapter adapter; private final ObjectMapper mapper; @@ -75,6 +80,21 @@ public ObjectNode dispatch(JsonNode request) { case "tools/call": return handleToolsCall(idNode, params); + case "resources/list": + return handleResourcesList(idNode); + + case "resources/read": + return handleResourcesRead(idNode, params); + + case "resources/templates/list": + return handleResourcesTemplatesList(idNode); + + case "prompts/list": + return handlePromptsList(idNode); + + case "prompts/get": + return handlePromptsGet(idNode, params); + case "ping": return buildJsonRpcResult(idNode, mapper.createObjectNode()); @@ -95,9 +115,15 @@ private ObjectNode handleInitialize(JsonNode id) { ObjectNode result = mapper.createObjectNode(); result.put("protocolVersion", MCP_PROTOCOL_VERSION); - // Server capabilities — we support tools + // Conditionally advertise capabilities based on what is declared in the spec ObjectNode capabilities = mapper.createObjectNode(); capabilities.putObject("tools"); + if (!adapter.getMcpServerSpec().getResources().isEmpty()) { + capabilities.putObject("resources"); + } + if (!adapter.getMcpServerSpec().getPrompts().isEmpty()) { + capabilities.putObject("prompts"); + } result.set("capabilities", capabilities); // Server info @@ -120,11 +146,17 @@ private ObjectNode handleInitialize(JsonNode id) { private ObjectNode handleToolsList(JsonNode id) { ObjectNode result = mapper.createObjectNode(); ArrayNode toolsArray = result.putArray("tools"); + Map labels = adapter.getToolLabels(); for (McpSchema.Tool tool : adapter.getTools()) { ObjectNode toolNode = mapper.createObjectNode(); toolNode.put("name", tool.name()); - + + String title = labels.get(tool.name()); + if (title != null) { + toolNode.put("title", title); + } + if (tool.description() != null) { toolNode.put("description", tool.description()); } @@ -175,6 +207,180 @@ private ObjectNode handleToolsCall(JsonNode id, JsonNode params) { } } + /** + * Handle resources/list request. + */ + private ObjectNode handleResourcesList(JsonNode id) { + ObjectNode result = mapper.createObjectNode(); + ArrayNode resourcesArray = result.putArray("resources"); + + for (Map entry : adapter.getResourceHandler().listAll()) { + ObjectNode node = mapper.createObjectNode(); + node.put("uri", entry.get("uri")); + node.put("name", entry.get("name")); + String title = entry.get("label"); + if (title != null) { + node.put("title", title); + } + String description = entry.get("description"); + if (description != null) { + node.put("description", description); + } + String mimeType = entry.get("mimeType"); + if (mimeType != null) { + node.put("mimeType", mimeType); + } + resourcesArray.add(node); + } + + return buildJsonRpcResult(id, result); + } + + /** + * Handle resources/read request. + */ + private ObjectNode handleResourcesRead(JsonNode id, JsonNode params) { + if (params == null) { + return buildJsonRpcError(id, -32602, "Invalid params: missing params"); + } + String uri = params.path("uri").asText(""); + if (uri.isEmpty()) { + return buildJsonRpcError(id, -32602, "Invalid params: uri is required"); + } + + try { + List contents = + adapter.getResourceHandler().read(uri); + ObjectNode result = mapper.createObjectNode(); + ArrayNode contentsArray = result.putArray("contents"); + for (McpResourceHandler.ResourceContent c : contents) { + ObjectNode contentNode = mapper.createObjectNode(); + contentNode.put("uri", c.uri); + if (c.mimeType != null) { + contentNode.put("mimeType", c.mimeType); + } + if (c.blob != null) { + contentNode.put("blob", c.blob); + } else { + contentNode.put("text", c.text != null ? c.text : ""); + } + contentsArray.add(contentNode); + } + return buildJsonRpcResult(id, result); + } catch (IllegalArgumentException e) { + Context.getCurrentLogger().log(Level.SEVERE, "Error handling resources/read", e); + return buildJsonRpcError(id, -32602, "Invalid params: " + e.getMessage()); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.SEVERE, "Error handling resources/read", e); + return buildJsonRpcError(id, -32603, "Internal error: " + e.getMessage()); + } + } + + /** + * Handle resources/templates/list request. + */ + private ObjectNode handleResourcesTemplatesList(JsonNode id) { + ObjectNode result = mapper.createObjectNode(); + ArrayNode templatesArray = result.putArray("resourceTemplates"); + + for (McpServerResourceSpec spec : adapter.getResourceHandler().listTemplates()) { + ObjectNode node = mapper.createObjectNode(); + node.put("uriTemplate", spec.getUri()); + node.put("name", spec.getName()); + if (spec.getLabel() != null) { + node.put("title", spec.getLabel()); + } + if (spec.getDescription() != null) { + node.put("description", spec.getDescription()); + } + if (spec.getMimeType() != null) { + node.put("mimeType", spec.getMimeType()); + } + templatesArray.add(node); + } + + return buildJsonRpcResult(id, result); + } + + /** + * Handle prompts/list request. + */ + private ObjectNode handlePromptsList(JsonNode id) { + ObjectNode result = mapper.createObjectNode(); + ArrayNode promptsArray = result.putArray("prompts"); + + for (McpServerPromptSpec spec : adapter.getPromptHandler().listAll()) { + ObjectNode promptNode = mapper.createObjectNode(); + promptNode.put("name", spec.getName()); + if (spec.getLabel() != null) { + promptNode.put("title", spec.getLabel()); + } + if (spec.getDescription() != null) { + promptNode.put("description", spec.getDescription()); + } + if (!spec.getArguments().isEmpty()) { + ArrayNode argsArray = promptNode.putArray("arguments"); + for (McpPromptArgumentSpec arg : spec.getArguments()) { + ObjectNode argNode = mapper.createObjectNode(); + argNode.put("name", arg.getName()); + if (arg.getLabel() != null) { + argNode.put("title", arg.getLabel()); + } + if (arg.getDescription() != null) { + argNode.put("description", arg.getDescription()); + } + argNode.put("required", arg.isRequired()); + argsArray.add(argNode); + } + } + promptsArray.add(promptNode); + } + + return buildJsonRpcResult(id, result); + } + + /** + * Handle prompts/get request — render a prompt with the provided arguments. + */ + @SuppressWarnings("unchecked") + private ObjectNode handlePromptsGet(JsonNode id, JsonNode params) { + if (params == null) { + return buildJsonRpcError(id, -32602, "Invalid params: missing params"); + } + String name = params.path("name").asText(""); + if (name.isEmpty()) { + return buildJsonRpcError(id, -32602, "Invalid params: name is required"); + } + + try { + JsonNode argumentsNode = params.get("arguments"); + Map arguments = argumentsNode != null + ? mapper.treeToValue(argumentsNode, Map.class) + : new java.util.HashMap<>(); + + List messages = + adapter.getPromptHandler().render(name, arguments); + + ObjectNode result = mapper.createObjectNode(); + ArrayNode messagesArray = result.putArray("messages"); + for (McpPromptHandler.RenderedMessage msg : messages) { + ObjectNode msgNode = mapper.createObjectNode(); + msgNode.put("role", msg.role); + ObjectNode contentNode = msgNode.putObject("content"); + contentNode.put("type", "text"); + contentNode.put("text", msg.text); + messagesArray.add(msgNode); + } + return buildJsonRpcResult(id, result); + } catch (IllegalArgumentException e) { + Context.getCurrentLogger().log(Level.SEVERE, "Error handling prompts/get", e); + return buildJsonRpcError(id, -32602, "Invalid params: " + e.getMessage()); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.SEVERE, "Error handling prompts/get", e); + return buildJsonRpcError(id, -32603, "Internal error: " + e.getMessage()); + } + } + /** * Build a JSON-RPC success response envelope. */ diff --git a/src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java b/src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java new file mode 100644 index 0000000..acfebf0 --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/McpResourceHandler.java @@ -0,0 +1,327 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.engine.exposes; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import io.naftiko.Capability; +import io.naftiko.spec.exposes.McpServerResourceSpec; + +/** + * Handles MCP resource reads by serving either dynamic (HTTP-backed) or static (file-backed) + * resources. + * + *

Dynamic resources reuse {@link OperationStepExecutor} exactly like tools — the same + * {@code call}/{@code steps}/{@code with} orchestration model applies.

+ * + *

Static resources read files from a {@code file:///} directory with strict path + * validation to prevent directory traversal.

+ */ +public class McpResourceHandler { + + private static final Logger logger = Logger.getLogger(McpResourceHandler.class.getName()); + + /** Allowed path segment characters — no {@code ..}, no special characters. */ + private static final Pattern SAFE_SEGMENT = Pattern.compile("^[a-zA-Z0-9._-]+$"); + + private final Capability capability; + private final List resourceSpecs; + private final OperationStepExecutor stepExecutor; + + public McpResourceHandler(Capability capability, List resources) { + this.capability = capability; + this.resourceSpecs = new ArrayList<>(resources); + this.stepExecutor = new OperationStepExecutor(capability); + } + + /** + * A single resource content item returned by {@link #read}. + */ + public static class ResourceContent { + public final String uri; + public final String mimeType; + /** UTF-8 text content, or {@code null} when binary. */ + public final String text; + /** Base64-encoded binary content, or {@code null} when text. */ + public final String blob; + + private ResourceContent(String uri, String mimeType, String text, String blob) { + this.uri = uri; + this.mimeType = mimeType; + this.text = text; + this.blob = blob; + } + + public static ResourceContent text(String uri, String mimeType, String text) { + return new ResourceContent(uri, mimeType, text, null); + } + } + + /** + * Read the resource identified by {@code uri}. Resolves URI template parameters from the URI + * when the spec uses {@code {param}} placeholders. + * + * @param uri the concrete resource URI requested by the agent + * @return list of content items (typically one) + * @throws IOException when reading a static file fails + * @throws IllegalArgumentException when no matching resource is found + */ + public List read(String uri) throws Exception { + for (McpServerResourceSpec spec : resourceSpecs) { + if (spec.isStatic()) { + // Static resources: exact URI or prefix match for file listings + if (uriMatchesStatic(spec, uri)) { + return readStatic(spec, uri); + } + } else { + // Dynamic resources: exact or template match + Map templateParams = matchTemplate(spec.getUri(), uri); + if (templateParams != null) { + return readDynamic(spec, uri, templateParams); + } + } + } + throw new IllegalArgumentException("Unknown resource URI: " + uri); + } + + /** + * Enumerate all concrete resource descriptors, expanding static location directories into + * per-file entries. + * + * @return list of {uri, spec} pairs for resources/list + */ + public List> listAll() { + List> result = new ArrayList<>(); + for (McpServerResourceSpec spec : resourceSpecs) { + if (spec.isStatic()) { + result.addAll(listStaticFiles(spec)); + } else if (!spec.isTemplate()) { + Map entry = new HashMap<>(); + entry.put("uri", spec.getUri()); + entry.put("name", spec.getName()); + entry.put("label", spec.getLabel() != null ? spec.getLabel() : spec.getName()); + entry.put("description", spec.getDescription()); + if (spec.getMimeType() != null) { + entry.put("mimeType", spec.getMimeType()); + } + result.add(entry); + } + } + return result; + } + + /** + * Return all resource template descriptors (specs whose URIs contain {@code {param}}). + */ + public List listTemplates() { + List templates = new ArrayList<>(); + for (McpServerResourceSpec spec : resourceSpecs) { + if (spec.isTemplate()) { + templates.add(spec); + } + } + return templates; + } + + // ── Static resource helpers ────────────────────────────────────────────────────────────────── + + private boolean uriMatchesStatic(McpServerResourceSpec spec, String requestedUri) { + String baseUri = spec.getUri(); + return requestedUri.equals(baseUri) || requestedUri.startsWith(baseUri + "/"); + } + + private List readStatic(McpServerResourceSpec spec, String requestedUri) + throws IOException { + Path baseDir = locationToPath(spec.getLocation()); + + String relative = requestedUri.equals(spec.getUri()) ? "" + : requestedUri.substring(spec.getUri().length() + 1); // strip leading / + + Path target; + if (relative.isEmpty()) { + // URI points directly at the base directory — look for an index file or error + target = baseDir; + } else { + // Validate each path segment to prevent directory traversal + String[] segments = relative.split("/"); + for (String segment : segments) { + if (!SAFE_SEGMENT.matcher(segment).matches()) { + throw new IllegalArgumentException( + "Invalid path segment in resource URI: " + segment); + } + } + target = baseDir.resolve(relative).normalize(); + } + + // Containment check after resolving symlinks + Path resolvedBase = baseDir.toRealPath(); + Path resolvedTarget = target.toRealPath(); + if (!resolvedTarget.startsWith(resolvedBase)) { + throw new IllegalArgumentException( + "Path traversal detected for resource URI: " + requestedUri); + } + + if (Files.isDirectory(resolvedTarget)) { + throw new IllegalArgumentException( + "Resource URI resolves to a directory, not a file: " + requestedUri); + } + + String mimeType = spec.getMimeType() != null ? spec.getMimeType() + : probeMimeType(resolvedTarget); + String text = Files.readString(resolvedTarget, StandardCharsets.UTF_8); + return List.of(ResourceContent.text(requestedUri, mimeType, text)); + } + + private List> listStaticFiles(McpServerResourceSpec spec) { + List> entries = new ArrayList<>(); + try { + Path baseDir = locationToPath(spec.getLocation()).toRealPath(); + Files.walk(baseDir) + .filter(Files::isRegularFile) + .sorted() + .forEach(file -> { + String relative = baseDir.relativize(file).toString().replace('\\', '/'); + String fileUri = spec.getUri() + "/" + relative; + String mimeType = spec.getMimeType() != null ? spec.getMimeType() + : probeMimeType(file); + Map entry = new HashMap<>(); + entry.put("uri", fileUri); + entry.put("name", spec.getName()); + entry.put("label", spec.getLabel() != null ? spec.getLabel() : spec.getName()); + entry.put("description", spec.getDescription()); + if (mimeType != null) { + entry.put("mimeType", mimeType); + } + entries.add(entry); + }); + } catch (IOException e) { + logger.warning("Cannot list static resource directory for '" + spec.getName() + + "': " + e.getMessage()); + } + return entries; + } + + private static Path locationToPath(String location) { + // location is a file:/// URI + return Paths.get(URI.create(location)); + } + + private static String probeMimeType(Path path) { + try { + String probed = Files.probeContentType(path); + if (probed != null) { + return probed; + } + } catch (IOException ignored) { + } + String name = path.getFileName().toString().toLowerCase(); + if (name.endsWith(".md")) return "text/markdown"; + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".yaml") || name.endsWith(".yml")) return "application/yaml"; + if (name.endsWith(".txt")) return "text/plain"; + if (name.endsWith(".xml")) return "application/xml"; + return "application/octet-stream"; + } + + // ── Dynamic resource helpers ───────────────────────────────────────────────────────────────── + + private List readDynamic(McpServerResourceSpec spec, String uri, + Map templateParams) throws Exception { + + Map parameters = new HashMap<>(templateParams); + if (spec.getWith() != null) { + parameters.putAll(spec.getWith()); + } + + OperationStepExecutor.HandlingContext found = + stepExecutor.execute(spec.getCall(), spec.getSteps(), parameters, + "Resource '" + spec.getName() + "'"); + + String text = extractContent(spec, found); + String mimeType = spec.getMimeType() != null ? spec.getMimeType() : "application/json"; + return List.of(ResourceContent.text(uri, mimeType, text)); + } + + private String extractContent(McpServerResourceSpec spec, + OperationStepExecutor.HandlingContext found) throws IOException { + + if (found == null || found.clientResponse == null + || found.clientResponse.getEntity() == null) { + return ""; + } + + String responseText = found.clientResponse.getEntity().getText(); + if (responseText == null) { + return ""; + } + + String mapped = stepExecutor.applyOutputMappings(responseText, spec.getOutputParameters()); + return mapped != null ? mapped : responseText; + } + + /** + * Match a concrete URI against a (possibly templated) spec URI. + * Returns a map of extracted template parameters, or {@code null} if no match. + */ + public static Map matchTemplate(String specUri, String concreteUri) { + if (specUri == null || concreteUri == null) { + return null; + } + if (!specUri.contains("{")) { + // Exact match required for non-template URIs + return specUri.equals(concreteUri) ? new HashMap<>() : null; + } + + // Build a regex from the template URI + StringBuilder regex = new StringBuilder("^"); + Pattern varPattern = Pattern.compile("\\{([a-zA-Z0-9_]+)\\}"); + Matcher varMatcher = varPattern.matcher(specUri); + List varNames = new ArrayList<>(); + int last = 0; + while (varMatcher.find()) { + regex.append(Pattern.quote(specUri.substring(last, varMatcher.start()))); + regex.append("([^/]+)"); + varNames.add(varMatcher.group(1)); + last = varMatcher.end(); + } + regex.append(Pattern.quote(specUri.substring(last))); + regex.append("$"); + + Matcher m = Pattern.compile(regex.toString()).matcher(concreteUri); + if (!m.matches()) { + return null; + } + Map params = new ConcurrentHashMap<>(); + for (int i = 0; i < varNames.size(); i++) { + params.put(varNames.get(i), m.group(i + 1)); + } + return params; + } + + public Capability getCapability() { + return capability; + } +} diff --git a/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java index 903f525..2141e77 100644 --- a/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/McpServerAdapter.java @@ -51,21 +51,34 @@ public class McpServerAdapter extends ServerAdapter { private final McpToolHandler toolHandler; private final List tools; + private final Map toolLabels; + private final McpResourceHandler resourceHandler; + private final McpPromptHandler promptHandler; public McpServerAdapter(Capability capability, McpServerSpec serverSpec) { super(capability, serverSpec); // Build MCP Tool definitions from the spec this.tools = new ArrayList<>(); + this.toolLabels = new HashMap<>(); Context.getCurrentLogger().log(Level.INFO, "Building MCP Tool definitions from the spec"); for (McpServerToolSpec toolSpec : serverSpec.getTools()) { this.tools.add(buildMcpTool(toolSpec)); + if (toolSpec.getLabel() != null) { + this.toolLabels.put(toolSpec.getName(), toolSpec.getLabel()); + } } // Create the tool handler (transport-agnostic) this.toolHandler = new McpToolHandler(capability, serverSpec.getTools()); + // Create the resource handler (transport-agnostic) + this.resourceHandler = new McpResourceHandler(capability, serverSpec.getResources()); + + // Create the prompt handler (transport-agnostic) + this.promptHandler = new McpPromptHandler(serverSpec.getPrompts()); + if (serverSpec.isStdio()) { initStdioTransport(); } else { @@ -144,10 +157,22 @@ public McpToolHandler getToolHandler() { return toolHandler; } + public McpResourceHandler getResourceHandler() { + return resourceHandler; + } + + public McpPromptHandler getPromptHandler() { + return promptHandler; + } + public List getTools() { return tools; } + public Map getToolLabels() { + return toolLabels; + } + @Override public void start() throws Exception { if (getMcpServerSpec().isStdio()) { diff --git a/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java b/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java index eaffd21..30dfc22 100644 --- a/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java +++ b/src/main/java/io/naftiko/engine/exposes/McpToolHandler.java @@ -19,13 +19,8 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.NullNode; import io.modelcontextprotocol.spec.McpSchema; import io.naftiko.Capability; -import io.naftiko.engine.Resolver; -import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.exposes.McpServerToolSpec; /** @@ -40,13 +35,11 @@ public class McpToolHandler { private static final Logger logger = Logger.getLogger(McpToolHandler.class.getName()); private final Capability capability; private final Map toolSpecs; - private final ObjectMapper mapper; private final OperationStepExecutor stepExecutor; public McpToolHandler(Capability capability, List tools) { this.capability = capability; this.toolSpecs = new ConcurrentHashMap<>(); - this.mapper = new ObjectMapper(); this.stepExecutor = new OperationStepExecutor(capability); for (McpServerToolSpec tool : tools) { @@ -78,35 +71,18 @@ public McpSchema.CallToolResult handleToolCall(String toolName, MapWhen {@code call} is non-null the matching client adapter is located, the request is + * built and {@link HandlingContext#handle()} is invoked. When {@code steps} is non-empty the + * full step-orchestration path runs instead. Throws {@link IllegalArgumentException} when the + * call reference cannot be resolved or neither {@code call} nor {@code steps} is defined.

+ * + * @param call the simple call spec, or {@code null} + * @param steps the step list, or {@code null}/empty + * @param parameters resolved parameters available for template substitution + * @param entityLabel human-readable label used in error messages (e.g. {@code "Tool 'my-tool'"}) + * @return the resulting {@link HandlingContext} + * @throws IllegalArgumentException when the call reference is invalid or neither mode is + * defined + * @throws Exception when the underlying HTTP request fails + */ + public HandlingContext execute(ServerCallSpec call, List steps, + Map parameters, String entityLabel) throws Exception { + if (call != null) { + HandlingContext found = findClientRequestFor(call, parameters); + if (found == null) { + throw new IllegalArgumentException( + "Invalid call for " + entityLabel + ": " + call.getOperation()); + } + found.handle(); + return found; + } else if (steps != null && !steps.isEmpty()) { + return executeSteps(steps, parameters).lastContext; + } else { + throw new IllegalArgumentException( + entityLabel + " has neither call nor steps defined"); + } + } + + /** + * Apply output parameter mappings to a JSON response string. + * + *

Parses {@code responseText} as JSON and evaluates each {@link OutputParameterSpec} in + * order, returning the first non-null mapped value serialised back to JSON. Returns + * {@code null} when no mapping matches (callers should fall back to the raw response).

+ * + * @param responseText the raw HTTP response body + * @param outputParameters the list of output parameter specs to try + * @return the first mapped JSON string, or {@code null} if none matched + */ + public String applyOutputMappings(String responseText, + List outputParameters) throws IOException { + if (responseText == null || responseText.isEmpty()) { + return null; + } + if (outputParameters == null || outputParameters.isEmpty()) { + return null; + } + JsonNode root = mapper.readTree(responseText); + for (OutputParameterSpec outputParam : outputParameters) { + JsonNode mapped = Resolver.resolveOutputMappings(outputParam, root, mapper); + if (mapped != null && !(mapped instanceof NullNode)) { + return mapper.writeValueAsString(mapped); + } + } + return null; + } + /** * Internal context for managing an HTTP client request-response pair. */ diff --git a/src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java b/src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java new file mode 100644 index 0000000..7cc2ce2 --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/McpPromptArgumentSpec.java @@ -0,0 +1,77 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.spec.exposes; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * An argument for an MCP prompt template. + * + * Arguments are always strings per the MCP specification. They are substituted into + * prompt templates via {@code {{name}}} placeholders. + */ +public class McpPromptArgumentSpec { + + private volatile String name; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String label; + + private volatile String description; + + /** Defaults to {@code true} — arguments are required unless explicitly set to false. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile Boolean required; + + public McpPromptArgumentSpec() {} + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean getRequired() { + return required; + } + + public void setRequired(Boolean required) { + this.required = required; + } + + /** + * Returns {@code true} unless {@code required} is explicitly set to {@code false}. + */ + public boolean isRequired() { + return required == null || required; + } +} diff --git a/src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java b/src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java new file mode 100644 index 0000000..e78ce71 --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/McpPromptMessageSpec.java @@ -0,0 +1,50 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.spec.exposes; + +/** + * A single message in an inline MCP prompt template. + * + * The {@code role} must be {@code "user"} or {@code "assistant"}. + * The {@code content} may contain {@code {{arg}}} placeholders that are substituted + * when the prompt is rendered via {@code prompts/get}. + */ +public class McpPromptMessageSpec { + + private volatile String role; + private volatile String content; + + public McpPromptMessageSpec() {} + + public McpPromptMessageSpec(String role, String content) { + this.role = role; + this.content = content; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java new file mode 100644 index 0000000..dc14974 --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/McpServerPromptSpec.java @@ -0,0 +1,101 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.spec.exposes; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * MCP Prompt Specification Element. + * + * Defines an MCP prompt template with typed arguments that agents can discover and render. + * Two source types are supported: + *
    + *
  • Inline ({@code template}): prompt messages declared directly in YAML with + * {@code {{arg}}} placeholders.
  • + *
  • File-based ({@code location}): prompt content loaded from a {@code file:///} URI; + * the file content becomes a single {@code user} role message.
  • + *
+ */ +public class McpServerPromptSpec { + + private volatile String name; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String label; + + private volatile String description; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List arguments; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List template; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String location; + + public McpServerPromptSpec() { + this.arguments = new CopyOnWriteArrayList<>(); + this.template = new CopyOnWriteArrayList<>(); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getArguments() { + return arguments; + } + + public List getTemplate() { + return template; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + /** + * Returns {@code true} when this prompt is rendered from a local file. + */ + public boolean isFileBased() { + return location != null; + } +} diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java new file mode 100644 index 0000000..e8b9d25 --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/McpServerResourceSpec.java @@ -0,0 +1,153 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package io.naftiko.spec.exposes; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ConcurrentHashMap; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.naftiko.spec.OutputParameterSpec; + +/** + * MCP Resource Specification Element. + * + * Defines an MCP resource that exposes data agents can read. Two source types are supported: + *
    + *
  • Dynamic ({@code call}/{@code steps}): backed by consumed HTTP operations — same + * orchestration model as tools.
  • + *
  • Static ({@code location}): served from local files identified by a + * {@code file:///} URI.
  • + *
+ */ +public class McpServerResourceSpec { + + private volatile String name; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String label; + + private volatile String uri; + + private volatile String description; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String mimeType; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile ServerCallSpec call; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile Map with; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List steps; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List outputParameters; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String location; + + public McpServerResourceSpec() { + this.steps = new CopyOnWriteArrayList<>(); + this.outputParameters = new CopyOnWriteArrayList<>(); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public ServerCallSpec getCall() { + return call; + } + + public void setCall(ServerCallSpec call) { + this.call = call; + } + + public Map getWith() { + return with; + } + + public void setWith(Map with) { + this.with = with != null ? new ConcurrentHashMap<>(with) : null; + } + + public List getSteps() { + return steps; + } + + public List getOutputParameters() { + return outputParameters; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + /** + * Returns {@code true} when this resource is served from a local file directory. + */ + public boolean isStatic() { + return location != null; + } + + /** + * Returns {@code true} when the URI contains {@code {param}} placeholders (resource template). + */ + public boolean isTemplate() { + return uri != null && uri.contains("{") && uri.contains("}"); + } +} diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java index 23d4720..5e9761a 100644 --- a/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/McpServerSpec.java @@ -42,6 +42,12 @@ public class McpServerSpec extends ServerSpec { @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List tools; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List resources; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List prompts; + public McpServerSpec() { this(null, 0, null, null); } @@ -51,6 +57,8 @@ public McpServerSpec(String address, int port, String namespace, String descript this.namespace = namespace; this.description = description; this.tools = new CopyOnWriteArrayList<>(); + this.resources = new CopyOnWriteArrayList<>(); + this.prompts = new CopyOnWriteArrayList<>(); } /** @@ -88,4 +96,12 @@ public List getTools() { return tools; } + public List getResources() { + return resources; + } + + public List getPrompts() { + return prompts; + } + } diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java index 1a1625d..507ac35 100644 --- a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java @@ -31,6 +31,9 @@ public class McpServerToolSpec { private volatile String name; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String label; + private volatile String description; @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -49,11 +52,12 @@ public class McpServerToolSpec { private final List outputParameters; public McpServerToolSpec() { - this(null, null); + this(null, null, null); } - public McpServerToolSpec(String name, String description) { + public McpServerToolSpec(String name, String label, String description) { this.name = name; + this.label = label; this.description = description; this.inputParameters = new CopyOnWriteArrayList<>(); this.steps = new CopyOnWriteArrayList<>(); @@ -68,6 +72,14 @@ public void setName(String name) { this.name = name; } + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + public String getDescription() { return description; } diff --git a/src/main/resources/schemas/capability-schema.json b/src/main/resources/schemas/capability-schema.json index ed5554e..befa449 100644 --- a/src/main/resources/schemas/capability-schema.json +++ b/src/main/resources/schemas/capability-schema.json @@ -746,7 +746,7 @@ }, "ExposesMcp": { "type": "object", - "description": "MCP Server exposition configuration. Exposes tools over MCP transport (Streamable HTTP or stdio).", + "description": "MCP Server exposition configuration. Exposes tools, resources and prompts over MCP transport (Streamable HTTP or stdio).", "properties": { "type": { "type": "string", @@ -784,6 +784,22 @@ "$ref": "#/$defs/McpTool" }, "minItems": 1 + }, + "resources": { + "type": "array", + "description": "List of MCP resources exposed by this server. Resources provide data that agents can read.", + "items": { + "$ref": "#/$defs/McpResource" + }, + "minItems": 1 + }, + "prompts": { + "type": "array", + "description": "List of MCP prompt templates exposed by this server. Prompts are reusable templates with typed arguments.", + "items": { + "$ref": "#/$defs/McpPrompt" + }, + "minItems": 1 } }, "required": [ @@ -828,6 +844,10 @@ "$ref": "#/$defs/IdentifierKebab", "description": "Technical name for the tool. Used as the MCP tool name." }, + "label": { + "type": "string", + "description": "Human-readable display name of the tool. Mapped to MCP 'title' in tools/list responses." + }, "description": { "type": "string", "description": "A meaningful description of the tool. Used for agent discovery. Remember, in a world of agents, context is king." @@ -939,6 +959,210 @@ ], "additionalProperties": false }, + "McpResource": { + "type": "object", + "description": "An MCP resource definition. Exposes data that agents can read. Either dynamic (backed by consumed HTTP operations via call/steps) or static (served from local files via location).", + "properties": { + "name": { + "$ref": "#/$defs/IdentifierExtended", + "description": "Technical name for the resource. Used as identifier in MCP resource listings." + }, + "label": { + "type": "string", + "description": "Human-readable display name of the resource. Mapped to MCP 'title' in protocol responses." + }, + "uri": { + "type": "string", + "description": "The URI that identifies this resource in MCP. Can use any scheme (e.g. config://, docs://, data://). For resource templates, use {param} placeholders." + }, + "description": { + "type": "string", + "description": "A meaningful description of the resource. Used for agent discovery. In a world of agents, context is king." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the resource content per RFC 6838 (e.g. application/json, text/markdown, text/plain).", + "pattern": "^[a-zA-Z0-9!#$&^_.+-]+\\/[a-zA-Z0-9!#$&^_.+-]+(?:\\s*;\\s*[a-zA-Z0-9!#$&^_.+-]+=(?:[a-zA-Z0-9!#$&^_.+-]+|\"[^\"]*\"))*$" + }, + "call": { + "type": "string", + "description": "For dynamic resources: reference to the consumed operation. Format: {namespace}.{operationId}.", + "pattern": "^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$" + }, + "with": { + "$ref": "#/$defs/WithInjector" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/OperationStep" + }, + "minItems": 1 + }, + "mappings": { + "type": "array", + "description": "Maps step outputs to the resource content.", + "items": { + "$ref": "#/$defs/StepOutputMapping" + } + }, + "outputParameters": { + "type": "array" + }, + "location": { + "type": "string", + "format": "uri", + "pattern": "^file:///", + "description": "For static resources: file:/// URI pointing to a directory whose files are served as MCP resources." + } + }, + "required": [ + "name", + "label", + "uri", + "description" + ], + "oneOf": [ + { + "required": ["call"], + "type": "object", + "properties": { + "outputParameters": { + "type": "array", + "items": { + "$ref": "#/$defs/MappedOutputParameter" + } + } + }, + "not": { "required": ["location"] } + }, + { + "required": ["steps"], + "type": "object", + "properties": { + "mappings": true, + "outputParameters": { + "type": "array", + "items": { + "$ref": "#/$defs/OrchestratedOutputParameter" + } + } + }, + "not": { "required": ["location"] } + }, + { + "required": ["location"], + "not": { + "anyOf": [ + { "required": ["call"] }, + { "required": ["steps"] } + ] + } + } + ], + "additionalProperties": false + }, + "McpPrompt": { + "type": "object", + "description": "An MCP prompt template definition. Prompts are reusable templates with typed arguments that agents can discover and render.", + "properties": { + "name": { + "$ref": "#/$defs/IdentifierExtended", + "description": "Technical name for the prompt. Used as identifier in MCP prompt listings." + }, + "label": { + "type": "string", + "description": "Human-readable display name of the prompt. Mapped to MCP 'title' in protocol responses." + }, + "description": { + "type": "string", + "description": "A meaningful description of the prompt and when to use it. Used for agent discovery." + }, + "arguments": { + "type": "array", + "description": "Typed arguments for this prompt template. Arguments are substituted into the template via {{arg}} placeholders.", + "items": { + "$ref": "#/$defs/McpPromptArgument" + }, + "minItems": 1 + }, + "template": { + "type": "array", + "description": "Inline prompt template as an array of messages. Each message has a role and content with {{arg}} placeholders.", + "items": { + "$ref": "#/$defs/McpPromptMessage" + }, + "minItems": 1 + }, + "location": { + "type": "string", + "format": "uri", + "pattern": "^file:///", + "description": "File-based prompt: file:/// URI pointing to a file containing the prompt template with {{arg}} placeholders. Content becomes a single 'user' message." + } + }, + "required": [ + "name", + "label", + "description" + ], + "oneOf": [ + { + "required": ["template"], + "not": { "required": ["location"] } + }, + { + "required": ["location"], + "not": { "required": ["template"] } + } + ], + "additionalProperties": false + }, + "McpPromptArgument": { + "type": "object", + "description": "An argument for an MCP prompt template. Arguments are always strings per MCP spec.", + "properties": { + "name": { + "$ref": "#/$defs/IdentifierExtended", + "description": "Argument name. Becomes a {{name}} placeholder in the template." + }, + "label": { + "type": "string", + "description": "The display name of the argument. Used for agent discovery." + }, + "description": { + "type": "string", + "description": "A meaningful description of the argument. Used for agent discovery." + }, + "required": { + "type": "boolean", + "description": "Whether the argument is required. Defaults to true.", + "default": true + } + }, + "required": [ + "name", + "description" + ], + "additionalProperties": false + }, + "McpPromptMessage": { + "type": "object", + "description": "A message in an inline MCP prompt template. Supports {{arg}} placeholders for argument substitution.", + "properties": { + "role": { + "type": "string", + "enum": ["user", "assistant"], + "description": "The role of the message sender." + }, + "content": { + "type": "string", + "description": "The message content. Supports {{arg}} placeholders for argument substitution." + } + }, + "required": ["role", "content"], + "additionalProperties": false + }, "ExposedResource": { "type": "object", "description": "An exposed resource", diff --git a/src/main/resources/specs/naftiko-specification-v0.5.md b/src/main/resources/specs/naftiko-specification-v0.5.md new file mode 100644 index 0000000..1362a4e --- /dev/null +++ b/src/main/resources/specs/naftiko-specification-v0.5.md @@ -0,0 +1,2321 @@ +# Naftiko Specification + +Version: 0.5 +Created by: Thomas Eskenazi +Category: Sepcification +Last updated time: March 5, 2026 12:40 PM +Reviewers: Kin Lane, Jerome Louvel, Jérémie Tarnaud, Antoine Buhl +Status: Draft + +# Naftiko Specification v0.5 + +**Version 0.5** + +**Publication Date:** March 2026 + +--- + +- **Table of Contents** + +## 1. Introduction + +The Naftiko Specification defines a standard, language-agnostic interface for describing modular, composable capabilities. In short, a **capability** is a functional unit that consumes external APIs (sources) and exposes adapters that allow other systems to interact with it. + +A Naftiko capability focuses on declaring the **integration intent** — what a system needs to consume and what it exposes — rather than implementation details. This higher-level abstraction makes capabilities naturally suitable for AI-driven discovery, orchestration and integration use cases, and beyond. When properly defined, a capability can be discovered, orchestrated, validated and executed with minimal implementation logic. The specification enables description of: + +- **Consumed sources**: External APIs or services that the capability uses +- **Exposed adapters**: Server interfaces that the capability provides (HTTP, REST, etc.) +- **Orchestration**: How calls to consumed sources are combined and mapped to realize exposed functions +- **External references**: Variables and resources resolved from external sources + +### 1.1 Schema Access + +The JSON Schema for the Naftiko Specification is available in two forms: + +- **Raw file** — The schema source file is hosted on GitHub: [capability-schema.json](https://github.com/naftiko/framework/blob/main/src/main/resources/schemas/capability-schema.json) +- **Interactive viewer** — A human-friendly viewer is available at: [Schema Viewer](https://naftiko.github.io/schema-viewer/) + +### 1.2 Core Objects + +**Capability**: The central object that defines a modular functional unit with clear input/output contracts. + +**Consumes**: External sources (APIs, services) that the capability uses to realize its operations. + +**Exposes**: Server adapters that provide access to the capability's operations. + +**Resources**: API endpoints that group related operations. + +**Operations**: Individual HTTP operations (GET, POST, etc.) that can be performed on resources. + +**Namespace**: A unique identifier for consumed sources, used for routing and mapping with the expose layer. + +**MCP Server**: An exposition adapter that exposes capability operations as MCP tools, enabling AI agent integration via Streamable HTTP or stdio transport. + +**ExternalRef**: A declaration of an external reference providing variables to the capability. Two variants: file-resolved (for development) and runtime-resolved (for production). Variables are explicitly declared via a `keys` map. + +### 1.3 Related Specifications. + + + +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" + } + } + }