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.Where mcp sits
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.Task prompt
Read status
Read the logs
Read the DAG
Retry the node
Check new run
Wrong id
Bad regex
The module in plain words
Connect
Discover
Author
Run
Watch
Steer
Learn
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.
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.
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.
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.
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.
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.
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.
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.
templateType: trigger | processor | terminal | all
Scenarios
Debug a failed run
The main path: an agent diagnoses and retries a failed run, tool by tool.Request passes the gate
The framework finds the tool
Ownership and status
Logs with a filter
Retry the failed node
Poll the new run
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.Learn the format
Check one plugin
Create the session
Start a workflow
Watch it
Wrong or foreign execution id
What the ownership check protects, and what it does not.A guessed id
The check finds nothing
Same answer for both
What it does not stop
Hostile or broken regex
How agent-supplied input is fenced: length, syntax and a time budget per line.The pattern arrives
Cheap checks first
Compile once, match per line
The line that explodes
The agent adapts
Key configured, key missing
The key filter in both states, and why the shipped default is open.Operator sets a key
A request without it
A request with it
What a key gives
A different key on REST
Look around, then stop everything
A bulk read of the whole system, then a bulk stop, with their limits.Ask for the overview
Read every session
Pick a workflow to stop
Drain, not kill
Check the outcome
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
A thin tool facade over providers, depending on core only
A thin tool facade over providers, depending on core only
Stateless, asynchronous streamable HTTP at /mcp
Stateless, asynchronous streamable HTTP at /mcp
Wrap every list result in a record
Wrap every list result in a record
Ownership is checked by (session, execution) with one error
Ownership is checked by (session, execution) with one error
Errors as short actionable text
Errors as short actionable text
Bound all agent-supplied input: regex, tail, caps
Bound all agent-supplied input: regex, tail, caps
Hints and wording as the safety layer for destructive tools
Hints and wording as the safety layer for destructive tools