> ## Documentation Index
> Fetch the complete documentation index at: https://yukta.infenia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP module

> The mcp module is Yukta's door for AI agents.

The mcp module is Yukta's door for AI agents. An agent (a program driven by a language model) connects over the Model Context Protocol (MCP, an open standard for letting models call tools) and does what a person does through the REST API or the web UI: create sessions, start workflows, steer a run, read its logs and look up plugins. The module holds no state and runs no workflows itself. It checks the request, calls core, and shapes the answer so a model can use it. This page is for thinking about it, not documenting it: open a component, play a scenario, then challenge the weak spots.

| Property | What it means |
| - | - |
| 23 tools, thin by design | Every tool is a few lines that call a provider, which calls core. The module repeats what the web layer does (ownership checks, control actions) without depending on it. |
| Stateless HTTP | Each request stands alone at /mcp. There is no connection to keep and nothing is pushed to the agent: it polls status and logs. |
| Made for a model reader | Errors say what to call next, lists are wrapped in records so none is cut short, and tool hints tell the client which calls only read and which can destroy. |
| Open unless you set a key | The endpoint accepts the same named keys as REST (roles VIEWER, OPERATOR, ADMIN, optional session limits) plus yukta.mcp.api-key, and each tools/call is checked against the key's role. No key is set in the shipped config, so on loopback it is still open; boot refuses to listen on another address without a key. |

<Note>
  Open risks, bugs and improvement ideas for this module are on the internal pages (sign-in required): [Risks](/internal/risks#mcp), [Bugs](/internal/bugs) and [Improvements](/internal/improvements#mcp).
</Note>

## Primer

People run Yukta through a web UI or REST calls. AI agents (a coding assistant in an IDE, a chat app that can use tools) cannot click, and raw REST is a poor fit for them: they need to discover what exists, need arguments described in words, and need errors that tell them what to do next. The **mcp module** offers the same operations as **tools** that an agent discovers and calls through the Model Context Protocol. An agent can then say 'start workflow X, wait, read the error lines, retry the failed step' with no custom integration code.

<Tip>
  Think of a staffed reception desk for robots. The building (core) already has a control room. People get in through the main entrance (REST, the UI). The robot entrance (**mcp**) has a clerk who checks the badge if one is required (the key filter), hands over a printed menu of what can be asked (the tool list, with each item's risk marked), makes sure you only ask about your own floor (the ownership check), rings the control room (core) and writes the answer in short plain sentences. The clerk keeps no files and forgets you between visits, so you come back and ask again whenever you want an update.
</Tip>

### Where mcp sits

| Layer | Role |
| - | - |
| AI agents | MCP clients: IDE assistants, chat apps, scripts |
| boot | Hosts the web server and sets the MCP endpoint /mcp, STATELESS, ASYNC |
| mcp | Tools, prompts, resources, the key filter. This page. (this module) |
| core | Sessions, the control bus, plugin registry, log store: the real work |
| plugin-api + plugins | The node types a workflow is built from |

mcp depends on core only. It does not depend on web, so REST and MCP are two independent doors into the same engine.

### The vocabulary in eight cards

| Term | Meaning | Example |
| - | - | - |
| MCP | Model Context Protocol: an open standard where a server lists tools, prompts and resources, and an AI client calls them with JSON messages (JSON-RPC) over HTTP. | Yukta is the server; Claude Desktop or an IDE assistant is the client. |
| Tool | One callable operation with a name, typed arguments, a description for the model, and a typed result. Yukta has 16. | start\_workflow(sessionId, workflowId) returns the new execution id. |
| Session and workflow | A session is a named workspace. It holds workflows: graphs of plugin nodes written as JSON. Creating a session stores its workflows; starting one runs it. | Session 'ci' holds workflows 'build' and 'deploy'. |
| Execution | One run of a workflow, with its own id, status, per-node progress and logs. A workflow can run many times, so it can have many executions. | Execution e-42 of 'build' failed at node 'compile'. |
| Control bus | The part of core that carries commands (pause, stop, skip, retry) to a run and reports its progress. mcp reaches it through one gateway object. | control\_node with action PAUSE becomes a pause command on the bus. |
| Signal, not state | Most control tools return as soon as the command has been sent. They do not wait until the node actually changed. The agent checks the status afterwards. | Result 'Pause signal sent.' means sent, not yet paused. |
| Ownership check | Every tool that names an execution also names its session, and mcp confirms the two belong together. An unknown id and an id from another session give the same error. | 'Execution not found: e-9' either way, so an agent cannot probe other sessions. |
| Stateless, so poll | The server remembers nothing between requests and cannot push updates. An agent that wants progress asks again and again. | Call get\_workflow\_status every few seconds while the run is active. |

### One example, end to end

An agent is told 'run e-42 in session ci failed, find out why and fix it'. Click a step to see what happens, why, and what can go wrong.

<Steps>
  <Step title="Task prompt">
    **What happens.** The client fetches the prompt 'debug-workflow' with a session id and an execution id. It returns a ready-made instruction telling the agent which tools to use, in what order.

    **Why it works this way.** Prompts are text templates the server offers. They carry the know-how (use status first, then logs, then the definition) so every agent starts from the same good routine instead of guessing.

    **Can go wrong.** The prompt text mentions RESTART and RESTART\_FROM\_NODE but not the node-level RETRY, so an agent following it literally may restart the whole run when only one node needs a retry.
  </Step>

  <Step title="Read status">
    **What happens.** The agent calls get\_workflow\_status(sessionId=ci, executionId=e-42). It gets the overall status, per-node task status and start and end times.

    **Why it works this way.** Before answering, mcp confirms that e-42 belongs to session ci by reading that session's own record. That record outlives the tracker's 10-minute index, so a run that finished an hour ago is still found.

    **Can go wrong.** A wrong session or execution id gives 'Execution not found: e-42. Use get\_workflow\_history to list executions.' The message is the same for an unknown id and a foreign one.
  </Step>

  <Step title="Read the logs">
    **What happens.** The agent asks for the logs with filterPattern 'ERROR|Exception' and tailLines 50. It gets the last 50 matching lines, plus how many lines matched in total.

    **Why it works this way.** The filter is a regular expression, compiled once and applied to each formatted line as it streams from the log store. Only the last 50 matches are kept in memory. Without tailLines the cap is 5,000 lines, so one call cannot flood the agent's context.

    **Can go wrong.** A pattern longer than 200 characters, a syntax error, or one that takes more than 50 ms on a single line is rejected with an explanation.
  </Step>

  <Step title="Read the DAG">
    **What happens.** The agent fetches the workflow definition (nodes, their plugin types and config, and the edges) to see what the failing node 'compile' is configured to do.

    **Why it works this way.** mcp returns core's definition record as it is. It already has a clean shape, so there is no second copy to keep in sync.

    **Can go wrong.** The definition may contain sensitive configuration, and everything in it goes into the agent's context. There is no masking in this module.
  </Step>

  <Step title="Retry the node">
    **What happens.** The agent calls control\_node with nodeId 'compile' and action RETRY. Core starts a new execution from that node and mcp returns its id in resultExecutionIds.

    **Why it works this way.** The same ownership check runs first. The tool is marked destructive so a client can ask a human before it runs. The command carries no agent identity, so the audit log cannot tell this from a human's click.

    **Can go wrong.** The result says the retry was started, not that it will succeed. There is no idempotency key, so an agent that repeats the call after a timeout may start two retries.
  </Step>

  <Step title="Check new run">
    **What happens.** The agent polls get\_workflow\_status with the new execution id until it is terminal, then reads the logs of the new run.

    **Why it works this way.** The transport is stateless, so the server cannot push 'done'. Polling is the whole mechanism and every poll is an ordinary, independent request.

    **Can go wrong.** Each poll repeats the ownership read. Many agents polling many runs add load that grows with the number of pollers.
  </Step>

  <Step title="Wrong id">
    **What happens.** The call fails with an error result (isError true) whose text names the id and the tool to use next.

    **Why it works this way.** Errors are plain sentences, not stack traces, because a model reads them and decides its next call from them.

    **Can go wrong.** An agent that ignores the hint can loop on the same bad id. Nothing limits how often it retries.
  </Step>

  <Step title="Bad regex">
    **What happens.** The call fails before any log is read, with 'Regex pattern too long', 'Invalid regex pattern: ...' or 'Regex filter is too expensive to evaluate; simplify the pattern.'

    **Why it works this way.** The agent writes the regex, so it is treated as untrusted input. A pattern can make matching take exponential time (catastrophic backtracking), so each line gets a 50 ms budget.

    **Can go wrong.** A pattern that is expensive on one long line fails the whole call. The agent has to rewrite it and ask again.
  </Step>
</Steps>

### The module in plain words

<Steps>
  <Step title="Connect">
    an agent posts a JSON-RPC message to /mcp. If a key is configured it must be in the headers.
  </Step>

  <Step title="Discover">
    the agent asks for the tool list and sees 23 tools with descriptions, typed arguments, result schemas and read-only or destructive hints.
  </Step>

  <Step title="Author">
    it asks for the session format, drafts a session and sends it with create\_session.
  </Step>

  <Step title="Run">
    it calls start\_workflow and gets an execution id.
  </Step>

  <Step title="Watch">
    it polls get\_workflow\_status and get\_execution\_logs, because nothing is pushed.
  </Step>

  <Step title="Steer">
    it pauses, steps, skips, retries or stops with control\_workflow and control\_node. Each call is checked against the session first.
  </Step>

  <Step title="Learn">
    it can read the overview and architecture resources, or ask for a plugin's details before writing a node.
  </Step>
</Steps>

## Components

### Endpoint gate

*key check, /sse alias*

Sits in front of the web server and decides whether a request to /mcp or /sse may continue, and rewrites the old /sse address to /mcp.

* It is a web filter with the highest priority, so it runs before anything else. It looks only at two paths, /mcp and /sse. Every other path (REST, the UI) passes straight through untouched.

* If no key is configured (yukta.mcp.api-key, yukta.api-key or yukta.api-keys), every request is let in. Otherwise the request must carry a key as 'Authorization: Bearer \<key>' or 'X-API-Key'; a miss gives 401, an ambiguous path 400. For a key that is not an unrestricted ADMIN, the filter reads the POST body (at most 1 MiB, else 413) and checks every tools/call in it against the key's role (McpToolAccess: VIEWER reads, OPERATOR starts and controls, ADMIN does everything including create\_session; an unknown tool needs ADMIN) and, for a session-limited key, against its sessions.

* The comparison is constant-time (MessageDigest.isEqual). A normal string comparison stops at the first wrong character, and an attacker timing many requests could learn the key one character at a time.

* A missing or wrong key gets HTTP 401 with an empty body. The check runs before the alias rewrite, so /sse is guarded exactly like /mcp.

* The alias exists because the endpoint used to be called /sse (although it speaks streamable HTTP, not the older SSE protocol). Renaming it to /mcp would have broken every client already configured. The filter rewrites the path of an /sse request to /mcp and passes it on.

* The same keys that protect /api also open /mcp, plus yukta.mcp.api-key, which is an ADMIN key. With named keys the Reactor context carries the actor 'key:\<name>'.

* **Guarantees.** When a key is configured, no request reaches a tool without presenting it, on either path, and the compare does not leak by timing.

* **State.** None except the key read once at startup. A key change needs a restart.

* **Ceiling.** Keys are static configuration: no expiry, and a change needs a restart. Rejections are not logged. Nothing limits request rate.

* **On failure.** A wrong key is a bare 401. There is no lockout, no counter and no log line for repeated failures.

**Settings:** `yukta.mcp.api-key, yukta.api-key, yukta.api-keys (empty = open)`, `Authorization: Bearer <key>`, `X-API-Key: <key>`, `paths: /mcp, /sse (alias)`

### Protocol runtime

*Spring AI MCP, stateless*

The Spring AI MCP server inside boot. It speaks the protocol, finds the tool, binds arguments from JSON, calls our method and turns the result or error into a reply.

* The settings in boot's application.yaml: server name 'yukta', protocol STATELESS, type ASYNC, stdio off, streamable-http endpoint /mcp. STATELESS means no session is kept between requests, so any request can be served without remembering earlier ones.

* ASYNC means tools return a Mono (a Reactor 'result that arrives later') instead of blocking a thread. Most tools simply forward the Mono core gives them. A few wrap a plain method call in Mono.fromCallable.

* Each tool declares generateOutputSchema = true, so the framework publishes a JSON schema of the result and returns it as structured content. A client can then read fields by name instead of parsing prose.

* Two framework bugs shape our code. A tool that returns Flux\<T> has its answer cut to the first element, so a 'list sessions' tool would show one session. A tool that returns Mono\<List\<T>> with an output schema crashes startup. The rule is to wrap every list in a record (SessionList, WorkflowHistory, PluginList). The integration test that does real JSON-RPC calls is the only test that would catch the startup crash.

* An IllegalArgumentException thrown or emitted by a tool is turned into a tool result with isError = true and the message as text. That is how every 'not found' and 'bad argument' error reaches the agent.

* **Guarantees.** Every tool call is an independent request. A wrapper record per list means no list result is truncated.

* **State.** None. Nothing about a client is kept between requests, so a restart loses nothing here and any server instance could answer any request.

* **Ceiling.** No streaming and no server-to-client notification in stateless mode, so progress can only be polled. Request size and timeouts are the framework's defaults, not set here.

* **On failure.** A framework upgrade can change the behaviours we work around. A startup failure here stops the whole boot application.

**Settings:** `spring.ai.mcp.server.protocol: STATELESS`, `spring.ai.mcp.server.type: ASYNC`, `spring.ai.mcp.server.stdio: false`, `spring.ai.mcp.server.streamable-http.mcp-endpoint: /mcp`, `spring.ai.mcp.server.enabled: true`

### Tool facade

*23 tools, hints, schemas*

The class that declares the 23 tools: names, descriptions, argument descriptions, risk hints. Each method hands over to one provider.

* Each tool is one annotated method. The description is written for the model: it says what the tool returns and which tool to call next (start\_workflow says 'use the execution id with get\_workflow\_status and get\_execution\_logs'). A model picks tools mostly from these sentences.

* Hints tell the client how risky a call is. Read-only tools (11 of 16) are marked readOnly and closed-world. start\_workflow and set\_node\_breakpoint are writes but not destructive. control\_workflow, control\_node and create\_session are destructive, and create\_session is also open-world because the workflows it installs can run commands and reach other systems. Its description asks the agent to confirm with the user first.

* Hints are advice for the client, not enforcement. A client that ignores them can call a destructive tool without asking anyone. Real enforcement is only the key filter.

* Argument handling is minimal on purpose. Enum arguments (the control action) are parsed by the framework. Required arguments that depend on the chosen action (executionId for most actions, workflowId for STOP\_ALL, fromNodeId for RESTART\_FROM\_NODE) are checked in the provider so the error can name the action.

* list\_sessions, get\_workflow\_history and list\_plugins wrap their lists in a record. get\_workflow\_status and get\_workflow\_details return core's own records directly, because they are already clean.

* **Guarantees.** Every tool has a name, description, typed arguments, an output schema and risk hints, and the tool list is checked by the integration test.

* **State.** None. Only references to the providers.

* **Ceiling.** Sixteen tools is already a lot of text for a model to choose from. Each new tool adds to every agent's context on every connection.

* **On failure.** Wrong descriptions mislead agents silently: nothing tests that the wording matches behaviour. The CLAUDE.md tool list, for example, is out of date.

### Prompts & resources

*templates and reference text*

Two prompt templates and three resources an agent can fetch without calling a tool.

* Prompt 'debug-workflow' (sessionId, executionId) returns a paragraph telling the agent to read the status, then the logs (with a filterPattern such as ERROR), then the definition, name the cause, and use RESTART or RESTART\_FROM\_NODE if a retry fits.

* Prompt 'create-session-config' tells the agent to call get\_session\_creation\_instructions and list\_plugins, draft nodes and edges and apply them with create\_session.

* Resource yukta://overview is a short description of Yukta and a typical flow. Resource yukta://architecture is a longer outline of the DAG, sessions, control bus and plugin logs. Both are fixed text inside the code.

* Resource yukta://sessions/\{sessionId}/executions/\{executionId}/logs is a template: the client fills in the two ids and gets the whole log (up to the 5,000-line cap) joined by newlines. It goes through the same ownership check and log reader as the tool, but takes no filter.

* The text is hand-written and is not generated from the code, so it can fall behind. The overview already lists skip and step as available, which matches the code today.

* **Guarantees.** The log resource gives the same ownership and cap protection as get\_execution\_logs.

* **State.** None. The prompt and overview text is fixed in the code.

* **Ceiling.** Static text cannot describe a plugin that was added later, and a 5,000-line log resource is a large single read for a client.

* **On failure.** A bad id gives the same isError answer as the tool. Stale prompt text sends agents to the wrong control action with no error to warn anyone.

**Settings:** `prompts: debug-workflow, create-session-config`, `yukta://overview`, `yukta://architecture`, `yukta://sessions/{sessionId}/executions/{executionId}/logs`

### Ownership check

*session owns execution*

A shared step that every tool naming an execution runs first: confirm the execution exists and belongs to the named session.

* It asks the control bus for the progress of (sessionId, executionId). If nothing comes back it fails with 'Execution not found: \<id>. Use get\_workflow\_history to list executions.' That is the same text for an unknown id and for an id owned by another session.

* The same text matters. Different messages ('no such execution' versus 'belongs to another session') would let a caller test which ids exist in sessions it does not own.

* It reads the session's own record instead of the tracker's global index. The index forgets finished runs after a time-to-live (10 minutes by default, yukta.tracker.cleanup-ttl). Reading the session record means status and logs of a run that ended an hour ago still work. An earlier version used the index and lost finished runs after 10 minutes.

* On success it passes the progress record forward. get\_workflow\_status simply returns it, so the check and the lookup are one step.

* It does not apply to STOP\_ALL, which names a workflow rather than an execution. It also does not apply to start\_workflow or get\_workflow\_details, which are session-scoped, not execution-scoped.

* It proves that the two ids match each other. It does not prove that the caller is allowed to touch that session: any caller who can reach the endpoint can list sessions and name any of them.

* **Guarantees.** No execution-scoped tool acts on an execution that is not recorded under the session named in the call, and unknown and foreign look identical.

* **State.** None. It reads core's per-session records, which live as long as core keeps them.

* **Ceiling.** One extra read on every execution-scoped call. Cheap in memory, but not free if the session records live in a slower store.

* **On failure.** If the record is gone (history trimmed, restart with an in-memory store) the answer is 'Execution not found', even for a run that genuinely existed. For the agent it looks the same as a wrong id.

### Run & control

*start, steer, restart*

Starts workflows and sends control commands to a run or one of its nodes, through core's control bus.

* start\_workflow(sessionId, workflowId) asks core to validate and start. It returns the new execution id. It sends no trigger payload and no idempotency key, although the REST start accepts both.

* control\_workflow takes one of six actions: PAUSE, RESUME, STOP, STOP\_ALL, RESTART, RESTART\_FROM\_NODE. All but STOP\_ALL need an executionId and pass the ownership check. STOP\_ALL needs a workflowId and stops every active run of that workflow in the session in the default DRAIN mode (in-flight work finishes). RESTART and RESTART\_FROM\_NODE return the new execution id in resultExecutionIds.

* control\_node takes ten actions: PAUSE, RESUME, STOP (optionally 'immediate'), STEP, STEP\_ENABLE, STEP\_DISABLE, SKIP, UNSKIP, RETRY and BREAKPOINT\_CLEAR. RETRY starts a new execution from that node, so it returns a new id. The others return a result saying the signal was sent.

* set\_node\_breakpoint pauses a node when a payload field equals a value, for example payload.status = error. BREAKPOINT\_CLEAR on control\_node removes it.

* Missing arguments are caught first with action-specific text, for example 'fromNodeId is required for action RESTART\_FROM\_NODE.' STOP actions record the reason 'Requested via MCP' when the agent gives none.

* A 'signal sent' result means the command was emitted onto the bus. It does not mean the node is paused. The agent must read the status to see the effect.

* **Guarantees.** Required arguments are checked before anything is sent, and execution-scoped actions only reach runs of the named session.

* **State.** None. State lives in core's run tracker and control bus.

* **Ceiling.** Commands go one at a time per call. STOP\_ALL is the only bulk action. Concurrency limits for runs are core's, and are off by default.

* **On failure.** Core errors (no such node, run already finished, no active execution to stop) pass through as isError text. The wording is core's, not shaped for agents in every case.

**Settings:** `WorkflowControlAction: PAUSE, RESUME, STOP, STOP_ALL, RESTART, RESTART_FROM_NODE`, `NodeControlAction: PAUSE, RESUME, STOP, STEP, STEP_ENABLE, STEP_DISABLE, SKIP, UNSKIP, RETRY, BREAKPOINT_CLEAR`, `default stop reason: Requested via MCP`

### Log reader

*tail + regex, capped*

Reads the stored plugin logs of one execution, filters lines with the agent's regex and returns the last N matches.

* The regex is checked first: empty means no filter, longer than 200 characters is rejected, a syntax error is reported with the engine's message. It is compiled once per call and reused for every line.

* Lines stream from core's log store, each is formatted ('\[timestamp] \[level] ...'), tested with a 'contains a match' search, and only the last N matches are kept. N is tailLines, or 5,000 if absent or not positive, and never more than 5,000. Streaming and keeping only the tail means memory stays bounded by N lines even for a very large log.

* The result reports executionId, totalLines (how many lines matched in all), returnedLines and the lines. An agent can see that 131 lines matched and it received 50, and ask for a narrower filter instead of paging.

* Each line has a 50 ms budget in the regex engine. The text is wrapped so that reading a character past the deadline throws, which cuts off a pattern like (a+)+\$ that would otherwise run for minutes. The whole call then fails with 'too expensive to evaluate; simplify the pattern.'

* The ownership check runs after the pattern check and before any line is read, so a foreign id never causes a log read.

* The resource form yukta://sessions/.../logs calls the same method with no filter and no tail.

* **Guarantees.** One call returns at most 5,000 lines, a hostile pattern cannot run longer than 50 ms per line, and a foreign execution's logs are never read.

* **State.** None. The log content is held by core's log store, which depends on how that store is configured.

* **Ceiling.** The whole execution log is still read from the store line by line. A multi-gigabyte log costs time even though little is kept. There is no paging: 5,000 lines is a hard ceiling per call.

* **On failure.** An expensive pattern fails the whole call, not just the slow line. Logs are raw plugin output that can contain secrets or text meant to steer a model; mcp passes them on as they are.

**Settings:** `tailLines (default and max 5,000)`, `filterPattern (max 200 chars)`, `per-line budget 50 ms`

### Session provider

*list, read, create*

Lists sessions, reads one, explains the session format, and creates (or overwrites) a session from a structured configuration.

* list\_sessions returns each session's id and workflow count. A session whose config cannot be loaded is left out with a warning in the server log, so one broken session does not hide the others. get\_session\_details returns the workflow ids of one session, or 'Session not found: ... Use list\_sessions' if it is unknown.

* create\_session takes a structured SessionConfigData: sessionId, name, description, initiator, optional tags and projectPath, and workflows. The framework generates the argument schema from it, so the agent sees the field names and the constraints (name and description at most 256 characters, workflows not empty). Earlier versions took a JSON string, which an agent had to escape by hand.

* It overwrites. A second create\_session with the same sessionId replaces the session, and workflows the new config no longer lists are removed from it (core logs 'Removed stale workflow'). That is why the tool is marked destructive. The result lists the session id and the workflow names, with an always-empty warnings list.

* get\_session\_creation\_instructions returns a text guide: naming rules, structure, an example, the DAG rules, the list of plugin types actually registered, and three common errors with fixes.

* Judgement: the example config in that guide does not match the real model. It uses node fields 'id', edge fields 'from', 'to', 'port', and plugin types (webhook-trigger, gradle-checker, log-aggregator) that are not registered. The real fields are nodeId, source, target, sourcePort, a workflowId and description per workflow, and plugin types such as HTTP\_REQUEST. The server is set to fail on unknown JSON properties, so an agent copying the example would be rejected.

* **Guarantees.** A created session passes core's validation, and core replaces the old session's workflow set in one call. Errors come back as readable text.

* **State.** None in mcp. Sessions live in core's store: memory by default (yukta.session.store-type: in-memory), so they vanish on restart unless a durable store is configured.

* **Ceiling.** list\_sessions reads one config per session with no paging. The creation guide is rebuilt on each call from the live plugin list.

* **On failure.** An invalid config is rejected by core's validation with its message. A teaching example that cannot validate wastes an agent's attempts.

**Settings:** `yukta.session.store-type (in-memory default)`, `name and description max 256 chars`, `sessionId constraint from core`

### Status aggregator

*sessions, runs, JVM*

Builds the get\_control\_bus\_status snapshot: sessions with active run counts, the 20 most recent executions, the plugin list and JVM memory and uptime.

* The optional filter picks one part: sessions, executions, plugins or health. Anything else is refused with the list of valid filters. With no filter, all four parts are built. Parts not asked for come back empty, and the memory block shows '0', '0', '0s' placeholders.

* It reads every session once, a few at a time (8 concurrently): the config for the workflow count and the execution history. One pass feeds both the sessions list and the recent executions, so a session is not read twice. A session that fails to load is skipped with a warning.

* A run counts as active unless its status is terminal. The check uses core's own status names and also the older names COMPLETED, FAILED and WORKFLOW\_STOPPED. An earlier version compared against names core never emits and so reported every run as active.

* Recent executions are sorted by start time (newest first, unknown start last) and cut to 20. Duration is 'running' for an open run, otherwise milliseconds.

* Every plugin is listed with status 'ACTIVE'. That value is fixed text, not a health check.

* Memory is used and maximum heap in MB and uptime from the JVM.

* **Guarantees.** A bad filter is refused with the valid values, one broken session does not fail the call, and the result never lists more than 20 executions.

* **State.** None. Built fresh on every call.

* **Ceiling.** Work grows with the number of sessions (and each session's history size). 8 at a time limits the load but not the total.

* **On failure.** A session that errors is silently missing from the totals (only a server-log warning). The result does not say it was skipped.

**Settings:** `filter: sessions | executions | plugins | health`, `concurrency 8 sessions`, `20 recent executions`

### Plugin catalogue

*types, details, guide*

Lets an agent discover node types, read one plugin's description and ports, and fetch a how-to-write-a-plugin guide.

* list\_plugins returns each registered plugin's type (such as HTTP\_REQUEST) and category (trigger, processor or terminal). get\_plugin\_details returns the type, category, description, a usage pattern, an optional UI design and the output ports. An unknown type gives 'Plugin not found: \<type>'. Type names are matched exactly, including case.

* The output ports shown come from the call without a configuration. Plugins whose ports depend on their config (a router with named branches, for example) therefore show only \['default'], which can mislead an agent wiring edges.

* Plugins also publish a typed list of configuration fields (name, type, required, default, options). get\_plugin\_details does not return it, so an agent has to guess config keys from the free-text usage pattern.

* get\_plugin\_creation\_guide returns an architecture note, code templates (filter: trigger, processor, terminal, all), a validation checklist, a testing strategy and a deployment guide.

* Judgement: the guide teaches a contract the code does not have. Its templates say 'extends TriggerPlugin' and 'execute(WorkflowContext)' and the text mentions a WorkflowRegistry. In the real plugin API, triggers, processors and terminals are interfaces, with a stream-based method each (process for a processor, consume for a terminal), plus settings such as getType and validateConfig. The templates would not compile.

* **Guarantees.** The listed plugins are exactly the ones registered in core's plugin registry at that moment.

* **State.** None. Reads the registry on every call.

* **Ceiling.** Reads are cheap. The ceiling is quality: what the catalogue says is only as good as each plugin's own description.

* **On failure.** An unknown type is a plain error. A stale guide fails later, when an agent's generated plugin does not compile.

**Settings:** `templateType: trigger | processor | terminal | all`

## Scenarios

### Debug a failed run

The main path: an agent diagnoses and retries a failed run, tool by tool.

<Steps>
  <Step title="Request passes the gate">
    The agent posts get\_workflow\_status to /mcp. The key filter sees a protected path, finds no key configured (or the right one) and lets it through. With a key set and a wrong value this is where it would stop with 401.
  </Step>

  <Step title="The framework finds the tool">
    Spring AI matches the tool name, reads sessionId and executionId from the JSON and calls the facade method. No connection state is involved, so any request can be the first.
  </Step>

  <Step title="Ownership and status">
    The ownership check reads the progress of (ci, e-42) from the session's record and returns it. The agent sees node 'compile' FAILED.
  </Step>

  <Step title="Logs with a filter">
    get\_execution\_logs with filterPattern 'ERROR|Exception' and tailLines 50 streams the log, keeps the last 50 matches and reports 131 matched in all. The agent knows its window is partial and can narrow the pattern.
  </Step>

  <Step title="Retry the failed node">
    control\_node with RETRY and nodeId 'compile' starts a new execution from that node. The reply carries the new id in resultExecutionIds. Nothing identifies the caller as an agent, and a repeated call would start a second retry.
  </Step>

  <Step title="Poll the new run">
    Because nothing is pushed, the agent calls get\_workflow\_status on the new id until it is terminal. Each call is an independent request.
  </Step>
</Steps>

### Create a session and run it

How an agent learns the format, creates a session and starts a workflow, and where the guide misleads it.

<Steps>
  <Step title="Learn the format">
    The agent calls get\_session\_creation\_instructions and list\_plugins. The first returns text and an example; the second returns the real plugin types. Where the two disagree, the registry is right and the example is not.
  </Step>

  <Step title="Check one plugin">
    get\_plugin\_details for a type returns description, usage pattern and ports. Ports are read without config, so a plugin with config-dependent ports shows only 'default'.
  </Step>

  <Step title="Create the session">
    create\_session takes the structured config. The tool is marked destructive and open-world, so a careful client asks the user first. Core validates and stores it. A session with the same id is replaced and its other workflows are removed.
  </Step>

  <Step title="Start a workflow">
    start\_workflow returns an execution id. There is no way to pass trigger data and no idempotency key. If the call times out and the agent repeats it, two runs may start.
  </Step>

  <Step title="Watch it">
    The agent polls status and logs. When it is done the agent reports back to the user. If core has a concurrency limit and it is hit, the start fails with core's error.
  </Step>
</Steps>

### Wrong or foreign execution id

What the ownership check protects, and what it does not.

<Steps>
  <Step title="A guessed id">
    An agent (or an attacker with the endpoint) calls get\_workflow\_status with session 'ci' and an execution id that belongs to session 'billing'.
  </Step>

  <Step title="The check finds nothing">
    The control bus holds no record of that id under 'ci', so the lookup is empty. The check cannot tell 'never existed' from 'belongs elsewhere', and that is the point.
  </Step>

  <Step title="Same answer for both">
    The error is 'Execution not found: \<id>. Use get\_workflow\_history to list executions.' Whichever case it was, the text is identical, so ids cannot be probed.
  </Step>

  <Step title="What it does not stop">
    The same caller can call list\_sessions, name 'billing' as the session and read its runs and logs legitimately. The check keeps the pair consistent. It does not restrict who may use which session.
  </Step>
</Steps>

### Hostile or broken regex

How agent-supplied input is fenced: length, syntax and a time budget per line.

<Steps>
  <Step title="The pattern arrives">
    get\_execution\_logs gets filterPattern '(a+)+\$' from an agent that was itself fed text by an untrusted source. The agent writes patterns, so it is as untrusted as any caller.
  </Step>

  <Step title="Cheap checks first">
    Length over 200 characters is refused. A syntax error is reported with the engine's message. Both happen before any ownership check or log read.
  </Step>

  <Step title="Compile once, match per line">
    The pattern is compiled once. For each line the text is wrapped with a 50 ms deadline. Reading a character past the deadline throws.
  </Step>

  <Step title="The line that explodes">
    On a long line of a's followed by a character that breaks the match, this pattern would take exponential time. The deadline fires and the whole call fails with 'Regex filter is too expensive to evaluate; simplify the pattern.' The server thread is released after about 50 ms.
  </Step>

  <Step title="The agent adapts">
    The error text tells the agent what to do. It sends a simpler pattern. Nothing stops it from trying again immediately and repeatedly.
  </Step>
</Steps>

### Key configured, key missing

The key filter in both states, and why the shipped default is open.

<Steps>
  <Step title="Operator sets a key">
    The operator sets a key (yukta.mcp.api-key, or the REST keys, which now cover /mcp too). Out of the box nothing is set: the endpoint is open, and the server listens on 127.0.0.1 only. A non-loopback address makes boot refuse to start without a key.
  </Step>

  <Step title="A request without it">
    A client posts to /mcp or /sse with no key or the wrong key. The filter compares in constant time and answers 401 with an empty body.
  </Step>

  <Step title="A request with it">
    With 'Authorization: Bearer \<key>' or 'X-API-Key: \<key>' the request continues. A request to /sse is rewritten to /mcp first and then served as normal.
  </Step>

  <Step title="What a key gives">
    Keys can be narrowed now: a VIEWER key cannot call start\_workflow, control\_\* or create\_session; a session-limited key must name a sessionId of its own. The single yukta.mcp.api-key is still an ADMIN key with every power.
  </Step>

  <Step title="A different key on REST">
    One key setting now covers REST and MCP (yukta.api-key and yukta.api-keys apply to both). The one trap left is the reverse: boot's start-up guard counts yukta.mcp.api-key as a key, so a server with only that key set may listen on any address while /api stays open (boot guide, guardMcpKeyOnly).
  </Step>
</Steps>

### Look around, then stop everything

A bulk read of the whole system, then a bulk stop, with their limits.

<Steps>
  <Step title="Ask for the overview">
    get\_control\_bus\_status with no filter builds all four parts: sessions with active run counts, the 20 most recent executions, the plugin list and JVM memory and uptime.
  </Step>

  <Step title="Read every session">
    Eight sessions at a time, the aggregator reads each config and history once. A session that fails is skipped with a log warning, so the totals can be missing one without telling the agent.
  </Step>

  <Step title="Pick a workflow to stop">
    The agent sees 3 active runs of workflow 'nightly' in session 'ci' and calls control\_workflow with STOP\_ALL and workflowId. No executionId is needed, so the ownership check is skipped. The session id scopes the stop.
  </Step>

  <Step title="Drain, not kill">
    The stop uses core's default DRAIN mode: in-flight work finishes. The REST endpoint can choose CANCEL or KILL, but the tool offers no mode. The result lists the stopped execution ids.
  </Step>

  <Step title="Check the outcome">
    The reply says how many executions were stopped, not that they ended. The agent reads status to confirm.
  </Step>
</Steps>

## Capabilities

Eighteen things an agent can do or rely on, grouped by what they are for.

### Discover

| Capability | What it does | How | Guarantee | Limit |
| - | - | - | - | - |
| Tool list with schemas | 23 tools with descriptions, typed arguments and result schemas. | Spring AI generates them from annotations; the output schema comes from the Java record. | Clients can read fields by name. | Text quality depends on hand-written descriptions. |
| Risk hints | Each tool is marked read-only, destructive or open-world. | readOnlyHint, destructiveHint and openWorldHint on the tool. | Clients can ask a human before risky calls. | Advice only; not enforced by the server. |
| Prompts | debug-workflow and create-session-config. | Templates that tell an agent which tools to use and in what order. | Same routine for every agent. | Static text; can lag behind tools. |
| Resources | Overview, architecture and an execution-log resource. | Fixed text plus one URI template that reads logs through the log reader. | Background reading without a tool call. | Hand-written; log resource has no filter. |

### Author

| Capability | What it does | How | Guarantee | Limit |
| - | - | - | - | - |
| Session format guide | Naming rules, structure, example and live plugin list. | get\_session\_creation\_instructions builds it on each call. | Plugin list is always current. | The inline example uses wrong field names and unregistered types. |
| Create or overwrite a session | Installs workflows from a structured config. | create\_session passes SessionConfigData to core's applyConfig. | Core validates; stale workflows are removed. | Overwrites by id with no preview or diff. |
| Plugin lookup | Types, categories, descriptions, usage and ports. | list\_plugins and get\_plugin\_details read the registry. | Always matches registered plugins. | No config schema; ports ignore config. |
| Plugin creation guide | Templates and checklists for a new plugin. | get\_plugin\_creation\_guide returns text and code templates. | A starting point. | Templates do not compile against the real plugin API. |

### Run

| Capability | What it does | How | Guarantee | Limit |
| - | - | - | - | - |
| Start a workflow | Starts a run and returns its id. | start\_workflow calls core's validate-and-start. | Execution id only after core accepts. | No trigger payload, no idempotency key. |
| Restart a run | Run again whole, or from a node. | RESTART and RESTART\_FROM\_NODE on control\_workflow; RETRY on control\_node. | New execution id returned. | No identity of the caller in the audit. |

### Steer

| Capability | What it does | How | Guarantee | Limit |
| - | - | - | - | - |
| Pause, resume, stop | Workflow-level and node-level control. | Commands emitted on the control bus after the ownership check. | Only the named session's runs are reachable. | Reports 'sent', not 'done'. |
| Step, skip, breakpoint | Debug-style steering of a node. | STEP\_ENABLE, STEP, STEP\_DISABLE, SKIP, UNSKIP, BREAKPOINT\_CLEAR and set\_node\_breakpoint. | Equal to what the UI offers. | Breakpoint compares a field to one string only. |
| Stop all of a workflow | Stops every active run of one workflow. | STOP\_ALL with workflowId, DRAIN mode. | One call, session-scoped. | No CANCEL or KILL choice. |

### Observe

| Capability | What it does | How | Guarantee | Limit |
| - | - | - | - | - |
| Status snapshot | Overall and per-node progress of one run. | get\_workflow\_status returns core's WorkflowProgress. | Works for runs after the tracker's 10-minute index expires. | Poll only. |
| Run history | All executions of a session with status and times. | get\_workflow\_history wraps core's list in a record. | Whole list, never truncated. | No paging. |
| Log tail with regex | Last N matching lines of a run. | Streaming filter, takeLast, 5,000-line cap, 200-char pattern, 50 ms per line. | Bounded memory and CPU. | Reads the whole log to find the tail. |
| System status | Sessions, 20 recent runs, plugins, memory. | get\_control\_bus\_status with optional filter. | One broken session does not fail the call. | Cost grows with sessions. |

### Protect

| Capability | What it does | How | Guarantee | Limit |
| - | - | - | - | - |
| Endpoint key | Optional shared key on /mcp and /sse. | Bearer or X-API-Key, constant-time compare, 401. | Off by default; on means no key, no entry. | One key for everyone. |
| Execution ownership | A run is reachable only through its own session. | ExecutionOwnership reads the session's record; same error either way. | No probing of ids across sessions. | Not a caller-level permission. |

## Limits, guarantees and what is only advice

The mcp module keeps no state of its own, so most 'what survives' answers come from core. This section lists the fences mcp sets, what it enforces and what it only recommends.

### Limits and defaults set in this module

| Item | Value | Why it exists |
| - | - | - |
| Endpoint | /mcp (streamable HTTP); /sse is a rewrite alias | Old clients keep working after the rename. |
| Transport mode | STATELESS, ASYNC, stdio off | Any request can be served alone; no connection to track. |
| API key | \*\*Partly.\*\* Keys or yukta.mcp.api-key; empty = open | Optional on loopback; boot refuses a non-loopback address without a key. |
| Log lines per call | 5,000 maximum; default when tailLines is absent or not positive | One call cannot flood an agent's context. |
| Regex pattern length | 200 characters | Limits the work a pattern can ask for. |
| Regex time | 50 ms per line | Stops catastrophic backtracking. |
| Status aggregation | 8 sessions read at a time; 20 recent executions | Bounds the load and the answer size. |
| Default stop reason | Requested via MCP | Audit text when the agent gives none. |
| STOP\_ALL mode | DRAIN (fixed) | The gateway's default; the tool has no mode argument. |
| Tracker index TTL (core) | 10 minutes (yukta.tracker.cleanup-ttl) | Ownership reads the session record instead, so this does not hide old runs. |

### Enforced or only advice?

What the server really prevents, compared with what it merely says.

| Control | Status | Detail |
| - | - | - |
| Key on /mcp and /sse | \*\*Partly.\*\* Enforced only if configured | Constant-time compare, 401. Off by default. |
| Execution belongs to the named session | \*\*Yes.\*\* Enforced | Same error for unknown and foreign ids. |
| Caller may use this session | \*\*Yes.\*\* Enforced for limited keys | A session-limited key must name its own sessionId in every call (list\_sessions is not open to it). A key without sessions reaches every session. |
| Read-only, destructive, open-world hints | \*\*Partly.\*\* Advice | Honoured only by clients that choose to. |
| Confirm before create\_session | \*\*Partly.\*\* Advice | Stated in the description, not checked. |
| Regex length and time | \*\*Yes.\*\* Enforced | Rejects with an explanation. |
| Log size | \*\*Yes.\*\* Enforced | 5,000 lines per call. |
| Request rate | \*\*No.\*\* Not enforced | No per-caller limit in this module. |
| Audit of who acted | \*\*Partly.\*\* With named keys | The actor 'key:\<name>' is set on the request; with the single mcp key, agent and human look alike. |
| Idempotent start | \*\*No.\*\* Not offered | REST supports an Idempotency-Key header; the tool does not. |

### Call semantics

| Topic | Behaviour |
| - | - |
| Calls are independent | Every request stands alone. There is no session, no subscription and no push. Agents poll. |
| Control results | 'Signal sent' and 'Stopped N executions' mean the command went onto the bus. Effects show in get\_workflow\_status. |
| Restart and retry | Create a new execution with a new id, returned in resultExecutionIds. The old run stays as history. |
| Errors | Plain-text IllegalArgumentException messages, shown as isError results, usually naming the next tool to call. |
| Lists | Always whole lists in a wrapper record (sessions, history, plugins). No paging. |
| Ordering | Within one call, steps run in order (check, then act). Across calls no ordering is promised: two agents can race. |

### What survives a restart

| Item | After the server restarts |
| - | - |
| mcp itself | \*\*Yes.\*\* Nothing to lose: no state, no sessions, no subscriptions. |
| Sessions and workflows | \*\*Note.\*\* Depends on core's store. In-memory is the default and loses them. |
| Run history and status | \*\*Note.\*\* Depends on core's tracker and log configuration. |
| Execution logs | \*\*Note.\*\* Depends on core's log store. If the logs are gone but the run record remains, the answer simply has no lines. |
| The API key | \*\*Yes.\*\* Read from configuration at startup. |
| An agent's polling | \*\*Yes.\*\* A reconnect is not needed. The next poll simply works. |

## Reference

### Tool catalogue (16)

Hints as declared on each tool. 'read-only' also means closed-world. Required arguments are listed first.

| Tool | What it does | Hint | Key parameters |
| - | - | - | - |
| list\_sessions | Sessions with workflow counts (wrapped in SessionList) | read-only | none |
| get\_session\_details | One session and its workflow ids | read-only | sessionId |
| get\_session\_creation\_instructions | Format guide, example, live plugin list, common errors | read-only | none |
| create\_session | Create or overwrite a session from a structured config | destructive, open-world | config (SessionConfigData) |
| get\_workflow\_details | The DAG definition: nodes and edges | read-only | sessionId, workflowId |
| start\_workflow | Start a run, return the execution id | write, not destructive | sessionId, workflowId |
| get\_workflow\_status | Progress snapshot with per-node status | read-only | sessionId, executionId |
| get\_workflow\_history | All executions of a session (wrapped in WorkflowHistory) | read-only | sessionId |
| get\_execution\_logs | Stored plugin logs, regex filter, tail | read-only | sessionId, executionId; tailLines, filterPattern |
| control\_workflow | Pause, resume, stop, stop all, restart, restart from node | destructive | sessionId, action; executionId, workflowId, fromNodeId, reason |
| control\_node | Ten node-level actions | destructive | sessionId, executionId, nodeId, action; immediate, reason |
| set\_node\_breakpoint | Pause a node when a field equals a value | write, not destructive | sessionId, executionId, nodeId, field, equalsValue |
| list\_plugins | Plugin types and categories (wrapped in PluginList) | read-only | none |
| get\_plugin\_details | Description, usage pattern, UI design, output ports | read-only | type |
| get\_plugin\_creation\_guide | Templates and checklists for writing a plugin | read-only | templateType (optional) |
| get\_control\_bus\_status | Sessions, 20 recent executions, plugins, JVM | read-only | filterType (optional) |

### Control actions

What each action needs and returns. 'Signal' results confirm emission, not effect.

| Tool and action | Needs | Returns |
| - | - | - |
| control\_workflow PAUSE / RESUME | executionId | Signal sent |
| control\_workflow STOP | executionId; reason optional | Execution stopped, with the stopped id |
| control\_workflow STOP\_ALL | workflowId (no executionId, no ownership check) | Ids of stopped executions |
| control\_workflow RESTART | executionId | New execution id |
| control\_workflow RESTART\_FROM\_NODE | executionId, fromNodeId | New execution id |
| control\_node PAUSE / RESUME | executionId, nodeId | Signal sent |
| control\_node STOP | executionId, nodeId; immediate, reason optional | Signal sent |
| control\_node STEP\_ENABLE / STEP\_DISABLE / STEP | executionId, nodeId | Signal sent |
| control\_node SKIP / UNSKIP | executionId, nodeId | Signal sent (input passes through while skipped) |
| control\_node RETRY | executionId, nodeId | New execution id started from that node |
| control\_node BREAKPOINT\_CLEAR | executionId, nodeId | Signal sent |
| set\_node\_breakpoint | executionId, nodeId, field, equalsValue | BREAKPOINT\_SET result |

### Spring AI 2.0 gotchas

Behaviours of the framework the code is shaped around.

| Situation | What goes wrong | What we do |
| - | - | - |
| A list returned as Flux\<T> | The async callback keeps only the first element, so the agent sees a list of one. | Return Mono\<WrapperRecord> (SessionList, WorkflowHistory, PluginList). |
| Mono\<List\<T>> with generateOutputSchema = true | Server startup crashes: the schema generator is given a null class for a parameterised type. | Same wrapper records. The JSON-RPC integration test is the only one that would catch it. |
| Tool failure | An IllegalArgumentException becomes an isError result with the message as text. | Write the message for an agent and name the next tool to call. |
| Parameter annotations | @McpToolParam is for tool arguments, @McpArg only for prompt arguments. | Use each in its own place. |
| Structured results | With generateOutputSchema the result must match the record's schema. | Return records, not maps or strings. |

### Error messages an agent will see

| Message | When |
| - | - |
| Execution not found: \<id>. Use get\_workflow\_history to list executions. | Unknown execution, or one owned by another session (identical on purpose) |
| Session not found: \<id>. Use list\_sessions to see available sessions. | get\_session\_details or get\_workflow\_history on an unknown session |
| executionId is required for action \<A>. | A control call without an execution id |
| fromNodeId is required for action RESTART\_FROM\_NODE. | Restart-from-node without a node |
| workflowId is required for action STOP\_ALL. | STOP\_ALL without a workflow |
| Regex pattern too long (max 200 characters). | filterPattern over 200 characters |
| Invalid regex pattern: \<engine message> | Regex syntax error |
| Regex filter is too expensive to evaluate; simplify the pattern. | More than 50 ms on one line |
| Unsupported filter: \<x>. Valid filters: sessions, executions, plugins, health; ... | get\_control\_bus\_status with a bad filter |
| Plugin not found: \<type> | get\_plugin\_details with an unregistered type |

### Prompts and resources

| Name | Kind | What it gives |
| - | - | - |
| debug-workflow | prompt (sessionId, executionId) | Instruction to read status, logs and definition, find the cause, suggest a fix |
| create-session-config | prompt | Instruction to read the format guide and plugin list, draft and apply a session |
| yukta://overview | resource | Short description and a typical tool flow |
| yukta://architecture | resource | DAG, sessions, control bus, reactive core, plugin logs |
| yukta://sessions/\{sessionId}/executions/\{executionId}/logs | resource template | Whole log of an execution (up to 5,000 lines), one line per row |

## Architecture decisions

<AccordionGroup>
  <Accordion title="A thin tool facade over providers, depending on core only">
    **Context.** Agents need the same operations as the REST API. The web module already has controllers, but depending on web would drag HTTP types, validation and Swagger into the agent surface.

    **Decision.** AppMcpTools holds only annotations and one-line hand-offs. Providers call core services (the control bus gateway, session and workflow services, plugin registry, log store) directly. mcp has its own small response records and does not depend on web.

    **Consequences.** Two surfaces over one engine can evolve independently, and tests are plain unit tests. The price is repeated rules (ownership semantics, STOP\_ALL behaviour) and parity gaps such as the missing payload and idempotency key.

    **Alternatives considered.** Call the REST API from mcp over HTTP: one source of behaviour, but an extra network hop, an auth problem inside the server and types squeezed through JSON. Share a service layer in core for both: the cleanest long-term, but it needs moving logic out of the web controllers.

    **Revisit when.** the two surfaces keep drifting (ownership, stop modes, validation), or a third surface appears that also needs the same rules.
  </Accordion>

  <Accordion title="Stateless, asynchronous streamable HTTP at /mcp">
    **Context.** A server that holds a connection per client needs sticky routing and cleanup, and stdio only works for a local child process.

    **Decision.** protocol STATELESS, type ASYNC, stdio off, endpoint /mcp. Tools return Mono. Each request is complete in itself.

    **Consequences.** Simple to run behind any proxy and nothing to lose at restart. No notifications from server to client: progress needs polling.

    **Alternatives considered.** Stateful streamable HTTP or SSE sessions: allows push and subscriptions, but needs session tracking and does not scale as easily. stdio: fine for a local IDE tool, useless for a shared server.

    **Revisit when.** agents need live progress (long runs, breakpoints waiting for an answer), or polling load becomes a problem.
  </Accordion>

  <Accordion title="Wrap every list result in a record">
    **Context.** With Spring AI 2.0, a Flux result is cut to its first element, and Mono\<List\<T>> with an output schema crashes startup.

    **Decision.** List tools return Mono\<SessionList>, Mono\<WorkflowHistory>, Mono\<PluginList>. The convention is written down and covered by a real JSON-RPC test.

    **Consequences.** Whole lists and structured output work. Results gain a small wrapper layer. The rule is easy to forget and only a startup or integration test shows the mistake.

    **Alternatives considered.** Return text instead of structured data: avoids the bugs but loses schemas and forces agents to parse. Wait for a framework fix: leaves the product broken meanwhile.

    **Revisit when.** a Spring AI upgrade fixes both behaviours, or we drop output schemas.
  </Accordion>

  <Accordion title="Ownership is checked by (session, execution) with one error">
    **Context.** Execution ids are the handle for status, logs and control. Callers could use another session's id, or probe which ids exist. The tracker's index forgets finished runs after 10 minutes.

    **Decision.** Every execution-scoped tool reads the progress of the pair through the control bus (the per-session record). An absent record and a foreign record give the same message. STOP\_ALL, which has no execution id, is scoped by session and workflow instead.

    **Consequences.** No probing, and old runs stay reachable. It is not an authorisation: it does not know who the caller is.

    **Alternatives considered.** Use the tracker's global index: simpler, but finished runs vanish after the TTL (an earlier bug). Check a caller-to-session permission: the right long-term answer, but needs identity (see ADR 5).

    **Revisit when.** Yukta hosts several teams on one server, or we introduce per-agent scopes.
  </Accordion>

  <Accordion title="One optional shared API key in a filter inside the module">
    **Context.** The MCP endpoint is mounted by Spring AI, outside the web module's REST filter, and must work for clients that can only send a header.

    **Decision.** McpEndpointFilter checks yukta.mcp.api-key (Bearer or X-API-Key, constant-time) on /mcp and /sse. Empty means open. The key is separate from yukta.api-key. The server binds to loopback by default.

    **Consequences.** A one-line setup that suits a single-user or single-team install. It gives no identity, scopes or rotation, and open-by-default relies on the bind address.

    **Alternatives considered.** Spring Security with OAuth: gives identities and scopes, but is a large dependency and setup for a local-first tool. Reuse yukta.api-key for both: one setting to remember, but a key for REST also opens MCP. A reverse proxy only: no code, but nothing protects a direct bind.

    **Revisit when.** the server is exposed to a network, shared between teams, or audited for who did what.
  </Accordion>

  <Accordion title="Errors as short actionable text">
    **Context.** A model decides its next call from the error. A stack trace or a generic 500 is useless to it.

    **Decision.** Providers emit IllegalArgumentException with sentences like 'Execution not found: X. Use get\_workflow\_history to list executions.' The framework turns them into isError results.

    **Consequences.** Agents recover well. The text is the interface, so changing wording changes agent behaviour. Core errors that are not shaped this way pass through as they are.

    **Alternatives considered.** Typed error codes in structured content: easier for programs, but a model reads prose better. Return success with an error field: hides failures from clients that look at isError.

    **Revisit when.** clients need machine-readable error handling or we add localisation.
  </Accordion>

  <Accordion title="Bound all agent-supplied input: regex, tail, caps">
    **Context.** The agent writes the regex, and logs can be large. A bad pattern can burn CPU; a big answer can fill the model's context.

    **Decision.** Pattern max 200 characters, compiled once, 50 ms per line via a deadline-checking char sequence, last-N streaming with a 5,000-line ceiling.

    **Consequences.** Bounded memory and time per call. Failing the whole call for one slow line is blunt, and the time budget depends on the machine.

    **Alternatives considered.** Run the regex in a separate thread with a timeout: stronger, but heavy per line. Allow substring match only: safest, but weak for 'ERROR|WARN' filters. A linear-time regex engine: removes the risk, adds a dependency and a different syntax.

    **Revisit when.** logs grow so large that scanning is slow, or a bypass of the budget is found.
  </Accordion>

  <Accordion title="Hints and wording as the safety layer for destructive tools">
    **Context.** Some tools can stop runs, or install workflows that run commands. The MCP standard lets a tool declare readOnly, destructive and openWorld hints.

    **Decision.** control\_workflow, control\_node and create\_session are marked destructive. create\_session is open-world and its description tells the agent to confirm with the user. Read tools are marked read-only.

    **Consequences.** Good clients ask before risky calls at no cost to the server. Nothing is enforced if the client ignores hints, so this is not a security boundary.

    **Alternatives considered.** Server-side approval step (a two-call confirm token): enforceable, but clumsy for agents. Separate read-only and write endpoints with different keys: strong, needs the scopes of ADR 5.

    **Revisit when.** agents run unattended with write access, or a client is found that ignores the hints.
  </Accordion>
</AccordionGroup>

## Glossary

| Term | Meaning | Note |
| - | - | - |
| MCP (Model Context Protocol) | An open standard that lets an AI client discover and call tools, prompts and resources on a server using JSON messages over HTTP. | It is the whole reason this module exists: it fixes how agents talk to Yukta. |
| Agent | A program driven by a language model that decides which tools to call to reach a goal. | It is the caller. Its behaviour is not fully predictable, so inputs from it are treated as untrusted. |
| Tool | A named operation with typed arguments and a typed result that an MCP client can call. | The 23 tools are the module's public API. |
| Prompt (MCP) | A named text template the server offers, which a client fetches with arguments. | Carries the 'how to approach this task' know-how. |
| Resource (MCP) | A readable document or data item at a URI, such as yukta://overview. | Background reading that needs no tool call. |
| JSON-RPC | A small protocol of JSON requests and replies with an id, a method and params. MCP is built on it. | Every tool call is one JSON-RPC message. |
| Streamable HTTP | The MCP transport that sends JSON-RPC over ordinary HTTP POSTs, optionally with a streamed reply. | Why /mcp is a normal HTTP endpoint behind any proxy. |
| Stateless protocol | A mode where the server keeps nothing between requests. | Easy to scale, but no push messages. Agents poll. |
| Reactive / Mono | Project Reactor's way to return a result that arrives later; a Mono holds at most one value. | All tool methods return Mono; the Flux truncation bug is why lists need wrappers. |
| Spring AI | The Spring project that provides the MCP server, the annotations and schema generation used here. | Its quirks (Flux truncation, a startup crash) shape the code. |
| Output schema | A JSON schema describing the shape of a tool's result. | Lets clients read fields by name and validates results. |
| Tool hints (annotations) | Flags on a tool: readOnly, destructive, openWorld. They describe risk to the client. | Clients use them to decide whether to ask a human first. They are not enforced by the server. |
| isError | A flag in a tool result that marks it as a failure, with the message as text. | How every 'not found' and 'bad argument' reaches the agent. |
| Session (Yukta) | A named workspace that holds workflow definitions and has its own run history. | Almost every tool takes a sessionId, and ownership is checked against it. |
| Workflow | A graph of plugin nodes and edges, written as JSON. | What agents create, read and start. |
| DAG | Directed acyclic graph: nodes joined by one-way edges with no loops. | The shape of every workflow; the creation guide explains it. |
| Node and edge | A node is one step using a plugin type; an edge connects one node's output port to another's input. The model uses nodeId, source, target and sourcePort. | The field names an agent must get right in create\_session. |
| Plugin | A pluggable node type (trigger, processor or terminal) such as HTTP\_REQUEST. | The catalogue tools list them; the registry is the source of truth. |
| Execution | One run of a workflow, with its own id, status and logs. | The handle for status, logs and control. |
| Control bus | Core's channel for commands to a run (pause, stop, skip) and for progress reports. Reached through the ControlBusGateway. | Every control tool ends here. |
| Signal | A command emitted onto the control bus. The tool returns when it is sent, not when it has taken effect. | Explains the 'sent, not done' behaviour. |
| Breakpoint | A rule that pauses a node when a payload field equals a value. | Used with set\_node\_breakpoint for debugging a live run. |
| Step mode | A node setting where it processes one message each time it is told to. | Used with STEP\_ENABLE, STEP and STEP\_DISABLE. |
| Skip | Pass a node's input straight through without running its plugin. | Used with SKIP and UNSKIP to work around a failing node. |
| Retry / Restart | RETRY re-runs from one node; RESTART re-runs the whole workflow; RESTART\_FROM\_NODE starts from a chosen node. All create a new execution. | Each gives a new execution id. |
| Drain (stop mode) | Stop that lets in-flight work finish before ending. | What STOP\_ALL uses. REST can also choose CANCEL or KILL. |
| Execution ownership | The rule that an execution is reached only through the session it belongs to. | Stops id mix-ups and hides which ids exist. |
| Tracker TTL | The time after which core's in-memory tracker forgets a finished run (10 minutes by default). | Why ownership reads the session's record instead. |
| API key | A shared secret that a client sends to prove it may call the endpoint. | The only access control here, and off by default. |
| Bearer token | A credential sent as 'Authorization: Bearer \<value>'. | One of two accepted ways to send the key. |
| Constant-time comparison | Comparing two secrets in a time that does not depend on how many leading characters match. | Stops an attacker learning the key by timing responses. |
| Loopback binding | A server listening only on 127.0.0.1, reachable only from the same machine. | The shipped default and today's main protection. |
| Catastrophic backtracking | A regex that takes exponential time on some inputs, such as (a+)+\$ on a long run of a's. | Why each line has a 50 ms budget. |
| Tail (of a log) | The last N lines. | What tailLines returns, after filtering. |
| Idempotency key | A client-chosen token that makes a repeated request return the first result instead of doing the work twice. | REST has it for starts; the tool does not. |
| ControlActor | A context value that names the sender of a control command, so it can be audited. | MCP does not set it, so agents are invisible in the audit. |
| Prompt injection | Text in data that tries to give instructions to a model reading it. | Logs and definitions flow into the model's context. |
| Polling | Asking again and again for an update instead of being told. | The only way to follow progress in stateless mode. |
| Wrapper record | A small record that holds a list, such as SessionList. | Avoids two Spring AI bugs with list results. |
| DTO (data transfer object) | A plain data class that carries a result between layers. | mcp has its own records and does not reuse web's. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.