Skip to main content
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.
Open risks, bugs and improvement ideas for this module are on the internal pages (sign-in required): Risks, Bugs and Improvements.

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.
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.

Where mcp sits

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

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.
1

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.
2

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.
3

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.
4

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.
5

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.
6

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.
7

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.
8

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.

The module in plain words

1

Connect

an agent posts a JSON-RPC message to /mcp. If a key is configured it must be in the headers.
2

Discover

the agent asks for the tool list and sees 23 tools with descriptions, typed arguments, result schemas and read-only or destructive hints.
3

Author

it asks for the session format, drafts a session and sends it with create_session.
4

Run

it calls start_workflow and gets an execution id.
5

Watch

it polls get_workflow_status and get_execution_logs, because nothing is pushed.
6

Steer

it pauses, steps, skips, retries or stops with control_workflow and control_node. Each call is checked against the session first.
7

Learn

it can read the overview and architecture resources, or ask for a plugin’s details before writing a node.

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.
1

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.
2

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.
3

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.
4

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.
5

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.
6

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.

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.
1

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.
2

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’.
3

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.
4

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.
5

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.

Wrong or foreign execution id

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

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’.
2

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.
3

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.
4

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.

Hostile or broken regex

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

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.
2

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.
3

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.
4

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.
5

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.

Key configured, key missing

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

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.
2

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.
3

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.
4

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.
5

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).

Look around, then stop everything

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

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.
2

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.
3

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.
4

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.
5

Check the outcome

The reply says how many executions were stopped, not that they ended. The agent reads status to confirm.

Capabilities

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

Discover

Author

Run

Steer

Observe

Protect

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

Enforced or only advice?

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

Call semantics

What survives a restart

Reference

Tool catalogue (16)

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

Control actions

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

Spring AI 2.0 gotchas

Behaviours of the framework the code is shaped around.

Error messages an agent will see

Prompts and resources

Architecture decisions

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.
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.
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.
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.
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.
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.
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.
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.

Glossary