You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Provenance: AI-researched (GitHub Copilot CLI / Claude Opus 5), human-directed. Findings are cited; uncertainties are flagged explicitly rather than smoothed over. Claims about Conductor's own code were re-verified against main at the time of writing and are cited by file/symbol.
Revision 4 — full rewrite. Supersedes the previous body and its two revision comments (deleted). Three things changed:
Six gaps between the previous design and the intended behaviour were identified and are now resolved as design decisions rather than left implicit — chiefly: serve all registered registries, expose workflows by default rather than by per-workflow opt-in, and let the caller choose foreground vs. background per invocation.
The startup-cost problem this creates (the registry index does not carry input schemas) is now called out and solved, because it is the gap most likely to make the feature feel broken in practice.
The gap
Today, the only way to let an AI agent invoke a Conductor workflow is to hand-write a skill that teaches it the CLI. That wrapper is pure overhead, and it is the wrong shape for the job.
Evidence that this is a real, recurring cost — not a hypothetical:
Wrapper
What it actually does
Cost
Bundled plugins/conductor/skills/conductor/
Teaches an agent conductor run/validate/show/registry/...
~117KB (~29K tokens), injected eagerly on non-native providers
ship skill (installed plugin)
"launches the ship Conductor workflow in the background and hands back its dashboard URL"
An entire SKILL.md whose only job is one conductor run --web-bg
fusion skill (installed plugin)
Runs the fusion workflow, then explains the output
Same pattern again
Rule of three, satisfied without anyone planning it. Every workflow that wants to be callable pays a skill tax.
And the tax buys the weaker artifact. A skill is advice the model may ignore; an MCP tool is a contract it invokes. A skill can be half-read, paraphrased, or skipped under context pressure — which is precisely what the bundled skill's own "DO NOT improvise workarounds / do not simulate the workflow yourself" warnings are defending against. A tool call has a typed schema, a result, and no opportunity to freelance.
The second half of the gap is the mirror image: when a run does go wrong, an agent has no structured way to find out why. It parses .events.jsonl out of a temp directory by hand (#116), or it gives up. That was #135, and it is the same server.
Proposal
conductor mcp serve — expose Conductor workflows as MCP tools, so any MCP host (Claude Code, Copilot CLI, Cursor, VS Code) can call a governed, deterministic, checkpointed, budget-capped workflow as a single tool call, and can diagnose it when it fails.
// host MCP config — zero arguments is the intended default
{ "mcpServers": { "conductor": { "command": "conductor", "args": ["mcp", "serve"] } } }
tools:
review_pr(pr_number: number, depth?: string) ← from official/review-pr.yaml
triage_incident(icm_id: string) ← from official/triage.yaml
prep_release(version: string, dry_run?: boolean) ← from team/release.yaml
conductor_run_status(run_id) conductor_await_run(run_id, ...)
conductor_cancel_run(run_id) conductor_list_runs(...)
The consumer never learns Conductor exists. It sees capabilities.
Why a workflow is the right unit of tool: the test is "do you want this procedure to be non-negotiable?" Conductor already enforces routing, gates, retry, budget caps, schema-validated outputs, and a checkpoint/audit trail. Exposing that as a tool makes the org's blessed pipelines callable primitives instead of prose an agent might follow.
Anthropic reports degradation >10–15; community ceiling ~50
OpenAI tools API
128
—
Each tool schema costs ~300–600 tokens; ~58 tools measured at ~55K tokens — a quarter of a 200K window consumed before the first user message. Anthropic's tool-writing guidance is explicit: don't map 1:1, build fewer high-level tools.
Zapier's answer to 30,000 actions is 14 static meta-tools (discover_zapier_actions → enable_zapier_action → execute_*) (docs.zapier.com). GitHub's answer is static toolsets configured at startup (docs.github.com). Windmill exposes every script/flow and has an open issue asking for exactly this granularity (windmill#7129).
This constraint governs two decisions below: exposing all registries by default (decision A) needs a hard cap and a discovery fallback, and absorbing #135 (decision E) must not spend the budget by default. Both are resolved via toolsets.
2. Workflows are long-running, so async is the default, not the fallback
This is the constraint that most shapes the design, and the transport question is a red herring for it.
It is true that stdio has no observed tool-call timeout (hours-long calls work in Claude Code / VS Code MCP), while Streamable HTTP is bounded by intermediaries. ⚠️ The widely-cited ~60s HTTP figure is community-observed, not primary-sourced — measure it, don't cite it. So a blocking call is technically survivable on stdio.
It is still the wrong default, for four reasons that have nothing to do with transport:
Process lifetime. An MCP stdio server is spawned and owned by the host. A workflow executed inside that process dies when the host quits, restarts, reloads its MCP config, or crashes — losing a 40-minute run. Conductor's --web-bg child is genuinely detached, so the run survives the session that started it. This alone settles it.
A blocking call blocks the whole agent session. For 20 minutes the host agent can do nothing else and the human sees a spinner. Conductor's product already rejected this posture — --web-bg exists, and the ship skill we are replacing already hands back a dashboard URL rather than blocking.
Cancellation is hostile. On Streamable HTTP, closing the connection is the cancellation signal — a transient disconnect can kill a healthy run. Async decouples run lifetime from connection lifetime.
Parallelism. One agent can start three workflows and await them all. Blocking serializes.
MCP Tasks (io.modelcontextprotocol/tasks, SEP-1686/SEP-2663) is precisely this pattern, standardized: submit → taskId → tasks/get → terminal result, with states working / input_required / completed / failed / cancelled. So an async-first design is not a workaround for missing Tasks support — it is the shape the spec converged on, and our run_id becomes a taskId when host support lands. ⚠️Zero major-host support as of Aug 2026 (claude-code#76571, typescript-sdk#1060) — hence our own tools now, that surface maps onto later.
The one real cost of async, stated honestly: the agent must drive the loop, and models stop polling — declaring "I started it!" and never checking back. Decision D below mitigates this deliberately rather than hoping.
3. Human gates can't rely on elicitation yet
Elicitation is specced and its schema subset fits Conductor's gates well:
Conductor
MCP elicitation
GateOption (single-select)
oneOf: [{const, title}, ...]
QuestionDef.choices + allow_free_text
enum + sibling string property
QuestionDef multi-question form
multiple flat properties in one requestedSchema
Sensitive / approval
URL-mode elicitation
But: no confirmed production support in Claude Code / Cursor / Copilot CLI, and spec 2026-07-28restructured it — server-initiated requests are gone, replaced by Multi Round-Trip Requests (server returns InputRequiredResult, client retries the same method with inputResponses + an opaque requestState).
Implication: the approval-URL pattern is the primary path (return the existing dashboard gate URL — Conductor already serves it, with conductor gate respond as the CLI half). Elicitation behind an experimental flag. This composes cleanly with async: a run that hits a gate reports status: awaiting_gate + the URL.
Design decisions
Six decisions, each resolving a gap between the naive design and the intended behaviour. These are the substance of this revision.
A. Serve all registered registries by default
Decision.conductor mcp serve with no arguments serves every registry in RegistryConfig.registries. --registry <name> (repeatable) narrows to a subset. This is the zero-config path: a user who has already run conductor registry add gets their workflows as tools without touching anything else.
Why not the previous single---registry design. It made the common case require configuration, and it meant a second registry was invisible until the user edited their MCP host config — a place they rarely look and which several hosts require a restart to reload.
Name collisions across registries. Two registries may both publish review-pr. Rules, in order:
Tool name is the slugified bare workflow name when unambiguous: review_pr.
On collision, all colliding tools are qualified — official_review_pr, team_review_pr. Not just the loser: a name that silently changes meaning when an unrelated registry is added is worse than one that is consistently qualified.
The collision is logged at startup at warning level, naming both registries.
--registry narrowing is the deterministic escape hatch.
Interaction with the tool cap. Serving everything makes constraint 1 load-bearing rather than theoretical. See decision C.
Local workflows.--workflow-dir <path> (repeatable, off by default) additionally exposes workflows from a local directory, for iterating on something not yet published. This is deliberately opt-in and never a tool parameter — see "Security".
B. Expose by default, opt out per workflow
Decision. Every workflow in a served registry is exposed unless it opts out. The schema block is:
workflow:
name: review-prdescription: Reviews a pull request across correctness, tests, and security.mcp:
expose: true # default true — set false to hidemode: async # async (default) | sync | auto — the *default*, caller can overrideread_only: false # → annotations.readOnlyHintdestructive: true # → annotations.destructiveHintestimated_minutes: 8# drives `auto`, and folded into the tool description
Why the previous opt-in design was wrong. It required each workflow YAML to carry an mcp: block before it became callable. No workflow in any existing registry has one — so conductor mcp serve --registry official would have exposed zero tools on day one, and a third-party registry would never be callable at all without forking it. The feature's entire premise is that a registry is already a catalogue of blessed procedures; requiring an edit to each one to admit that is backwards.
The counter-argument, and the answer. Default-on means a registry gaining a workflow silently gains a tool. That is real, and it is handled by the same machinery that handles everything else about the exposed set:
--allow <name> / --deny <name> (repeatable, glob-capable) at the server level, which beats the YAML.
The startup summary logs the exact exposed set, count, and any auto-qualified names.
Hash-stamping (decision F) means a changed workflow is detectable even when its name is unchanged.
expose: false remains for a workflow that is genuinely not a callable unit — a sub-workflow fragment, an internal helper.
C. Toolsets, so absorbing #135 doesn't spend the tool budget
Decision. The static tools are grouped into toolsets, selected at startup — GitHub MCP's pattern, chosen because it is the one that survives constraint 1.
Your "debug/diagnostics when there are issues". Environment/provider health, pre-flight validation, and the raw launch logs. 3 slots.
discovery
auto
conductor_find_workflow, conductor_run_workflow
Replaces workflows above the cap. See below.
Default footprint is N + 4. The introspection and diagnostic surface is available to the agents that need it (a debugging session, a CI agent) without taxing every session that just wants to call one workflow.
Discovery fallback. When the exposed workflow count exceeds --max-direct-tools (default 25), the server automatically drops the per-workflow tools and serves the Zapier-shaped pair instead: conductor_find_workflow(query) → conductor_run_workflow(name, inputs). It logs loudly when it does this, naming the count and the threshold. Between the threshold and a known client cap it warns but still serves direct tools. This is what makes decision A (serve everything) safe: a 60-workflow org degrades to discovery rather than to a silently truncated tool list.
D. The caller chooses foreground vs. background, per call
Decision. Async is the default (constraint 2), but mode: in YAML is only a default. Every generated tool carries one reserved parameter:
Workflow's mcp.mode decides. async → return a handle immediately. sync → block to completion. auto → sync if estimated_minutes ≤ 2, else async.
0
Force background. Return the handle immediately.
> 0
Start detached, then block up to N seconds for a terminal state. Returns the full result if it finishes, or the same handle + progress if it doesn't.
Why this shape. The previous design put the sync/async choice only in the workflow YAML, so a calling agent could not say "just wait for this one, it's quick" or "don't block, I'll check back." That is exactly the decision the caller is best placed to make and the author is not — it depends on what else the agent is doing.
The critical property: _wait_seconds > 0 is not "run in the MCP server process." The run is always detached via launch_background(); foreground mode only changes whether the tool call waits for it. So a host that quits mid-call still leaves a live, resumable, dashboard-visible run — the process-lifetime argument in constraint 2 is preserved in every mode. This makes foreground genuinely free of downside, which the naive "sync means run it inline" design was not.
Consequence: a dashboard URL is returned in every mode, including foreground, because there is always a --web-bg-style child with a port. That satisfies "after triggering it provides the dashboard url" unconditionally.
conductor_await_run remains, and is the mitigation for the "models stop polling" failure mode:
It collapses N poll round-trips into one, so the agent's natural "call it and read the answer" behaviour works.
It stays inside safe transport windows by construction (bounded, caller-specified).
On timeout it returns instructive text ("still running, step 3/7, call conductor_await_run again") rather than a bare status blob — the next action is in the response.
It emits notifications/progress throughout, which is broadly supported (Claude Code, Cursor, VS Code all render progress), so the human sees liveness even when the model is idle.
The run is detached regardless, so an agent that wanders off has cost nothing.
Decision.#135 ("MCP server for log/state introspection") is closed as superseded by this issue, and its tool surface ships here as the introspect and diagnose toolsets (decision C).
Why the previous "complementary, don't conflate" framing was wrong. It was right that the two have different consumers — one operates Conductor, one consumes it. It was wrong that they are different servers. An agent that calls review_pr and gets status: failed needs conductor_run_logs in the same breath, from the same connection, without the user having installed a second MCP server. The requirement "it has debug/diagnostics tools if there are issues triggering or running workflows" is not a separate product; it is the error path of this one.
The toolset mechanism is what makes the merge cost-free: absorbing #135 adds zero tools to the default footprint.
What does not carry over:#135's first half — the authoring skill kit (scaffolds, validation skills, dry-run helpers). That is content, not protocol, and it is genuinely a different piece of work. It should be re-filed as its own issue if still wanted; the bundled plugins/conductor/skills/conductor/ has since covered part of it.
What is added beyond #135, specifically for "issues triggering":
conductor_doctor(provider?) — a direct wrapper over providers/diagnostics.py::gather(), which already returns structured to_dict()-able data. Answers "why did this fail before it started": missing credentials, unreachable provider, unknown model, disabled reasoning effort.
conductor_validate_workflow(name) — conductor validate as a tool, so an agent can check a workflow before burning a run on it, including plugin-source and skill resolution.
F. Provenance: hash-stamp and pin at session start
Decision. At startup the server resolves every exposed workflow to an immutable identity — for GitHub registries the already-resolved commit SHA (registry/index.py::load_index resolves refs to SHAs precisely so this is possible), for path registries a content hash of the YAML. That identity is:
included in every invocation's structured result,
written to the run index (gap 1),
re-checked on a configurable interval; drift is logged loudly and the tool description is not silently updated mid-session.
Rug-pull — tool definitions changing after the user approved them — is the documented MCP attack. Conductor is unusually exposed to it because a registry-sourced workflow is remote, user-authored content that can contain type: script shell steps.
Gaps in Conductor this exposes
Gap 1 — no durable run registry keyed by run_id(in scope; hard prerequisite for async)
When a bg run finishes, its process exits: the dashboard is gone, /api/state is unreachable, and the PID file is stale. The only surviving record is $TMPDIR/conductor/conductor-<name>-<ts>-<runid>.events.jsonl — found by globbing a temp directory the OS may reap. So conductor_run_status on a completed run has no answer today, which is exactly the common case (agent starts a run, comes back later).
Proposed shape. A small append-only run index under ~/.conductor/runs/ — the directory rundir.py::runs_dir() already owns, alongside the PID files and dashboard token files, so it inherits the same home-isolation seam tests already use. One record per run: run_id, workflow name + registry + content hash (decision F), start/end timestamps, terminal status, the rendered output: dict, cost, and the paths to the events JSONL and the bg capture logs. Written by engine/event_log.py::EventLogSubscriber on terminal events, so no new plumbing in the engine.
conductor_run_status / conductor_await_run read live state from /api/info when the port is up and fall back to the index once it isn't — one code path, two sources. conductor_list_runs and conductor_run_logs read it directly.
Kept in this issue rather than split out: it has no independent consumer today, and specifying it apart from the lifecycle that needs it invites a shape that doesn't fit. It improves conductor status and post-hoc debugging as a side effect.
Gap 2 — output: is untyped, so there is no outputSchema(partially deferred, stated honestly)
WorkflowConfig.output is dict[str, str] — Jinja templates, verified at config/schema.py:3614. There is no declared type for any output key, so MCP's outputSchema cannot be published faithfully.
v1 behaviour: results are returned as structuredContent (the rendered output: dict, which is structured JSON) plus a text-block fallback, with no outputSchema. This is spec-legal and is what most servers do; the cost is that the model discovers the result shape by reading it rather than by contract.
Follow-up, on this issue: derive outputSchema from the referenced agents' OutputField types, which are typed (dict[str, OutputField], config/schema.py:1258), falling back to string for any key whose provenance can't be traced. Or add explicit types to output:. Shares machinery with #230.
This is the one place the feature under-delivers against "it gets the inputs and outputs" — inputs are contractual in v1, outputs are structured-but-undeclared.
Gap 3 — the registry index doesn't carry input schemas (the practical blocker)
registry/index.py::WorkflowInfo is {description, path} — verified. It has no input field. So the mapping-table line "the registry is the tool list" is only two-thirds true: the registry gives names and descriptions, and every inputSchema requires resolving, fetching, and parsing the workflow YAML itself.
Why this matters more than it sounds: MCP stdio servers are respawned constantly by hosts (every session, every config reload, every restart). A GitHub-backed registry of 20 workflows would mean 20 HTTP fetches on every spawn before the server can answer tools/list. Worse, a workflow YAML may not even load standalone — !file includes and ${VAR} interpolation are resolved relative to a context the server may not have.
Resolution, in order:
Extend the index format. Add optional input: (and optional mcp:) to WorkflowInfo, so a registry can publish its tool schemas directly. Backwards-compatible — the field is optional and absent indexes keep working. Registry authors get a conductor registry build-index style helper that populates it from the YAML.
Cache the parsed result under registry/cache.py::get_cache_base() ($CONDUCTOR_HOME/cache/registries/), keyed by the resolved commit SHA. A SHA-keyed entry is immutable, so a warm cache makes startup a no-network operation.
Fall back to fetch-and-parse when neither is available, with a startup timeout and a degraded mode: a workflow whose schema can't be resolved is still exposed, with a permissive object input schema and a description saying so, rather than vanishing from the tool list.
Without at least (2), the feature will feel broken — slow, network-dependent startup on a surface hosts restart aggressively.
Gap 4 — descriptions are attack surface
workflow.description flows into a tool description the host model reads. A workflow from a git-backed registry is user-controlled remote content. Needs sanitization (strip control characters, prompt-injection-shaped markers, nested instructions) and a hard length cap before it reaches the schema.
Security
The threat model is unusually sharp because Conductor workflows contain type: script shell steps and can be fetched from git registries. An MCP server that runs arbitrary user-authored workflows is an RCE surface wearing a tool schema.
Never expose a run_workflow(path) tool over arbitrary local paths. Registry- or allowlist-scoped only. --workflow-dir (decision A) is a startup argument the user typed, never a tool parameter the model can supply — the distinction is the whole control.
Serving all registries by default (decision A) is safe only because a registry is something the user explicitly added via conductor registry add. It never auto-discovers a registry.
Sanitize and length-cap any YAML-authored text that reaches a tool description (gap 4).
Hash-stamp and pin at session start; warn on drift (decision F).
Annotations (readOnlyHint, destructiveHint) are hints, not guarantees — useful for host consent prompts, not a control.
--allow / --deny scoping beats YAML, so an operator can constrain a registry they don't own.
The diagnose toolset returns log contents. Spill files and tool output can contain secrets (runtime.tool_output spill files are explicitly documented as such), so conductor_run_logs must apply the same redaction the dashboard does, and must be tail-bounded.
Current spec is 2026-07-28, not 2025-11-25 (spec, changelog). Major break: stateless protocol (no initialize handshake), new server/discover, HTTP GET stream replaced by subscriptions/listen, MRTR replaces all server-initiated requests, Tasks moved to an extension repo, SSE deprecated, all results carry resultType.
⚠️Live risk, verified at time of writing:pyproject.toml declares mcp>=1.28.1 with no upper bound; uv.lock pins 1.28.1; PyPI's latest mcp is 2.0.0. Any lock refresh can jump major versions. SDK 2.x restructured substantially (FastMCP → MCPServer, mcp_types split out, logging helpers deprecated), and the existing MCP client (src/conductor/mcp/manager.py, importing mcp.client.stdio) is in the blast radius — this can break MCP tool support today with no server work involved at all.
Action: add an upper bound to the mcp constraint. One line, do it first, independent of everything else here.
Recommendation: target SDK 1.x for v1 so the server ships without a client migration riding along. The async design is unaffected either way — run_id-based start/await is our own tool surface, not a protocol feature. The 2.x / 2026-07-28 move is a follow-up on this issue.
Suggested scope for v1
Everything needed lives in this issue, including the groundwork it depends on. Neither piece of groundwork has an independent consumer today, so both are sequenced here rather than split out.
Bound the mcp dependency (pyproject.toml). One line, no dependencies on anything else, protects the existing client. Do it first.
Durable run index under ~/.conductor/runs/, keyed by run_id, written by EventLogSubscriber on terminal events (gap 1). Independently testable via conductor status.
Registry index input: extension + SHA-keyed parse cache (gap 3). Independently useful — makes conductor registry list <name> richer too.
conductor mcp serve over stdio, serving all registered registries by default, --registry / --allow / --deny / --workflow-dir scoping (decision A).
Async-first lifecycle with caller-side override (decision D): start → detached run + handle, gated on confirmed workflow_started; _wait_seconds on every tool; conductor_await_run / conductor_run_status / conductor_cancel_run / conductor_list_runs. Dashboard URL in every mode.
Toolsets (decision C): workflows + runs default; --max-direct-tools with automatic discovery fallback; startup warning near known client caps.
Deferred to follow-ups on this issue, not separate ones: outputSchema (gap 2, blocked on typing output:); Streamable HTTP + OAuth; SDK 2.x / 2026-07-28 migration; MCP Tasks (map run_id → taskId when hosts ship it); elicitation-based gates.
Re-file separately if still wanted:#135's authoring skill kit half (scaffolds, validation skills, dry-run helpers) — content, not protocol, and partly covered by the bundled conductor skill since.
Suggested PR split: (0) the pin → (1) run index → (2) index extension + cache → (3–6) the server → (7–8) the toolsets → (9–10) result shape and gates.
Prior art
Platform
Model
Note
n8n
Workflows as tools (MCP Server Trigger)
No typed input declaration; ~60s timeout; community pattern is immediate ACK + task id → poll
Windmill
Scripts/flows as tools
Token-scoped; open issue for finer per-flow control (#7129)
Dify
Flows as tools
Native MCP in the 2026 release
Zapier MCP
14 meta-tools for 30K actions
The discovery pattern (decision C)
GitHub MCP
Static toolsets
The toolset pattern (decision C)
Temporal / Dagster
Control plane only
Start/signal/query — deliberately not workflows-as-tools
The gap
Today, the only way to let an AI agent invoke a Conductor workflow is to hand-write a skill that teaches it the CLI. That wrapper is pure overhead, and it is the wrong shape for the job.
Evidence that this is a real, recurring cost — not a hypothetical:
plugins/conductor/skills/conductor/conductor run/validate/show/registry/...shipskill (installed plugin)conductor run --web-bgfusionskill (installed plugin)Rule of three, satisfied without anyone planning it. Every workflow that wants to be callable pays a skill tax.
And the tax buys the weaker artifact. A skill is advice the model may ignore; an MCP tool is a contract it invokes. A skill can be half-read, paraphrased, or skipped under context pressure — which is precisely what the bundled skill's own "DO NOT improvise workarounds / do not simulate the workflow yourself" warnings are defending against. A tool call has a typed schema, a result, and no opportunity to freelance.
The second half of the gap is the mirror image: when a run does go wrong, an agent has no structured way to find out why. It parses
.events.jsonlout of a temp directory by hand (#116), or it gives up. That was #135, and it is the same server.Proposal
conductor mcp serve— expose Conductor workflows as MCP tools, so any MCP host (Claude Code, Copilot CLI, Cursor, VS Code) can call a governed, deterministic, checkpointed, budget-capped workflow as a single tool call, and can diagnose it when it fails.The consumer never learns Conductor exists. It sees capabilities.
Why a workflow is the right unit of tool: the test is "do you want this procedure to be non-negotiable?" Conductor already enforces routing, gates, retry, budget caps, schema-validated outputs, and a checkpoint/audit trail. Exposing that as a tool makes the org's blessed pipelines callable primitives instead of prose an agent might follow.
Why this is nearly free in Conductor
Most of the surface already exists and maps 1:1:
Tool.inputSchemaconfig/schema.py::InputDef(type/required/default/description)registry/config.py::RegistryConfig.registries→registry/index.py::RegistryIndex.workflowsTool.name/descriptionworkflow.name/workflow.descriptionengine.run(inputs) -> dict[str, Any]from theoutput:blockcli/bg_runner.py::launch_background()→BackgroundLaunch(url, run_id, stderr_log, stdout_log, workflow_started)start_new_session=True(POSIX) /CREATE_NEW_PROCESS_GROUP|CREATE_BREAKAWAY_FROM_JOB(Windows)_wait_for_workflow_start+GET /api/infostarted_at(#410)GET /api/info,GET /api/state,cli/pid.py::scan_pid_files(),conductor status --jsonPOST /api/stop//api/kill→handle_dashboard_stop(writes a checkpoint)conductor_cancel_run.events.py::WorkflowEventEmitterpub/subGateOption(label/value + optionalprompt_for),QuestionDef(text/choices/free-text)providers/diagnostics.py::gather()returns structured, already-to_dict()-able reportconductor doctoris a thin renderer over it. Exposing it as a tool is nearly free. ✅ verifiedengine/event_log.py::EventLogSubscriberwrites structured.events.jsonlfor every runweb/auth.pytoken file +resolve_cli_tokenmcp>=1.28.1is already a direct dependencyThree constraints that must shape the design
Research turned up three hard constraints. Each one invalidates the naive version of this feature.
1. Tool-list caps make "one tool per workflow, unbounded" a trap
Each tool schema costs ~300–600 tokens; ~58 tools measured at ~55K tokens — a quarter of a 200K window consumed before the first user message. Anthropic's tool-writing guidance is explicit: don't map 1:1, build fewer high-level tools.
Zapier's answer to 30,000 actions is 14 static meta-tools (
discover_zapier_actions→enable_zapier_action→execute_*) (docs.zapier.com). GitHub's answer is static toolsets configured at startup (docs.github.com). Windmill exposes every script/flow and has an open issue asking for exactly this granularity (windmill#7129).This constraint governs two decisions below: exposing all registries by default (decision A) needs a hard cap and a discovery fallback, and absorbing #135 (decision E) must not spend the budget by default. Both are resolved via toolsets.
2. Workflows are long-running, so async is the default, not the fallback
This is the constraint that most shapes the design, and the transport question is a red herring for it.
It is true that stdio has no observed tool-call timeout (hours-long calls work in Claude Code / VS Code MCP), while Streamable HTTP is bounded by intermediaries.⚠️ The widely-cited ~60s HTTP figure is community-observed, not primary-sourced — measure it, don't cite it. So a blocking call is technically survivable on stdio.
It is still the wrong default, for four reasons that have nothing to do with transport:
--web-bgchild is genuinely detached, so the run survives the session that started it. This alone settles it.--web-bgexists, and theshipskill we are replacing already hands back a dashboard URL rather than blocking.MCP Tasks (⚠️ Zero major-host support as of Aug 2026 (claude-code#76571, typescript-sdk#1060) — hence our own tools now, that surface maps onto later.
io.modelcontextprotocol/tasks, SEP-1686/SEP-2663) is precisely this pattern, standardized: submit →taskId→tasks/get→ terminal result, with statesworking/input_required/completed/failed/cancelled. So an async-first design is not a workaround for missing Tasks support — it is the shape the spec converged on, and ourrun_idbecomes ataskIdwhen host support lands.The one real cost of async, stated honestly: the agent must drive the loop, and models stop polling — declaring "I started it!" and never checking back. Decision D below mitigates this deliberately rather than hoping.
3. Human gates can't rely on elicitation yet
Elicitation is specced and its schema subset fits Conductor's gates well:
GateOption(single-select)oneOf: [{const, title}, ...]QuestionDef.choices+allow_free_textstringpropertyQuestionDefmulti-question formrequestedSchemaBut: no confirmed production support in Claude Code / Cursor / Copilot CLI, and spec
2026-07-28restructured it — server-initiated requests are gone, replaced by Multi Round-Trip Requests (server returnsInputRequiredResult, client retries the same method withinputResponses+ an opaquerequestState).Implication: the approval-URL pattern is the primary path (return the existing dashboard gate URL — Conductor already serves it, with
conductor gate respondas the CLI half). Elicitation behind an experimental flag. This composes cleanly with async: a run that hits a gate reportsstatus: awaiting_gate+ the URL.Design decisions
Six decisions, each resolving a gap between the naive design and the intended behaviour. These are the substance of this revision.
A. Serve all registered registries by default
Decision.
conductor mcp servewith no arguments serves every registry inRegistryConfig.registries.--registry <name>(repeatable) narrows to a subset. This is the zero-config path: a user who has already runconductor registry addgets their workflows as tools without touching anything else.Why not the previous single-
--registrydesign. It made the common case require configuration, and it meant a second registry was invisible until the user edited their MCP host config — a place they rarely look and which several hosts require a restart to reload.Name collisions across registries. Two registries may both publish
review-pr. Rules, in order:review_pr.official_review_pr,team_review_pr. Not just the loser: a name that silently changes meaning when an unrelated registry is added is worse than one that is consistently qualified.--registrynarrowing is the deterministic escape hatch.Interaction with the tool cap. Serving everything makes constraint 1 load-bearing rather than theoretical. See decision C.
Local workflows.
--workflow-dir <path>(repeatable, off by default) additionally exposes workflows from a local directory, for iterating on something not yet published. This is deliberately opt-in and never a tool parameter — see "Security".B. Expose by default, opt out per workflow
Decision. Every workflow in a served registry is exposed unless it opts out. The schema block is:
Why the previous opt-in design was wrong. It required each workflow YAML to carry an
mcp:block before it became callable. No workflow in any existing registry has one — soconductor mcp serve --registry officialwould have exposed zero tools on day one, and a third-party registry would never be callable at all without forking it. The feature's entire premise is that a registry is already a catalogue of blessed procedures; requiring an edit to each one to admit that is backwards.The counter-argument, and the answer. Default-on means a registry gaining a workflow silently gains a tool. That is real, and it is handled by the same machinery that handles everything else about the exposed set:
--allow <name>/--deny <name>(repeatable, glob-capable) at the server level, which beats the YAML.expose: falseremains for a workflow that is genuinely not a callable unit — a sub-workflow fragment, an internal helper.C. Toolsets, so absorbing #135 doesn't spend the tool budget
Decision. The static tools are grouped into toolsets, selected at startup — GitHub MCP's pattern, chosen because it is the one that survives constraint 1.
workflowsrunsconductor_await_run,conductor_run_status,conductor_cancel_run,conductor_list_runsintrospectconductor_run_events,conductor_node_detail,conductor_plan_treediagnoseconductor_doctor,conductor_validate_workflow,conductor_run_logsdiscoveryconductor_find_workflow,conductor_run_workflowworkflowsabove the cap. See below.Default footprint is N + 4. The introspection and diagnostic surface is available to the agents that need it (a debugging session, a CI agent) without taxing every session that just wants to call one workflow.
Discovery fallback. When the exposed workflow count exceeds
--max-direct-tools(default 25), the server automatically drops the per-workflow tools and serves the Zapier-shaped pair instead:conductor_find_workflow(query)→conductor_run_workflow(name, inputs). It logs loudly when it does this, naming the count and the threshold. Between the threshold and a known client cap it warns but still serves direct tools. This is what makes decision A (serve everything) safe: a 60-workflow org degrades to discovery rather than to a silently truncated tool list.D. The caller chooses foreground vs. background, per call
Decision. Async is the default (constraint 2), but
mode:in YAML is only a default. Every generated tool carries one reserved parameter:_wait_secondsmcp.modedecides.async→ return a handle immediately.sync→ block to completion.auto→syncifestimated_minutes≤ 2, elseasync.0> 0Why this shape. The previous design put the sync/async choice only in the workflow YAML, so a calling agent could not say "just wait for this one, it's quick" or "don't block, I'll check back." That is exactly the decision the caller is best placed to make and the author is not — it depends on what else the agent is doing.
The critical property:
_wait_seconds > 0is not "run in the MCP server process." The run is always detached vialaunch_background(); foreground mode only changes whether the tool call waits for it. So a host that quits mid-call still leaves a live, resumable, dashboard-visible run — the process-lifetime argument in constraint 2 is preserved in every mode. This makes foreground genuinely free of downside, which the naive "sync means run it inline" design was not.Consequence: a dashboard URL is returned in every mode, including foreground, because there is always a
--web-bg-style child with a port. That satisfies "after triggering it provides the dashboard url" unconditionally.conductor_await_runremains, and is the mitigation for the "models stop polling" failure mode:conductor_await_runagain") rather than a bare status blob — the next action is in the response.notifications/progressthroughout, which is broadly supported (Claude Code, Cursor, VS Code all render progress), so the human sees liveness even when the model is idle.E. #135 is absorbed, not cross-referenced
Decision. #135 ("MCP server for log/state introspection") is closed as superseded by this issue, and its tool surface ships here as the
introspectanddiagnosetoolsets (decision C).Why the previous "complementary, don't conflate" framing was wrong. It was right that the two have different consumers — one operates Conductor, one consumes it. It was wrong that they are different servers. An agent that calls
review_prand getsstatus: failedneedsconductor_run_logsin the same breath, from the same connection, without the user having installed a second MCP server. The requirement "it has debug/diagnostics tools if there are issues triggering or running workflows" is not a separate product; it is the error path of this one.The toolset mechanism is what makes the merge cost-free: absorbing #135 adds zero tools to the default footprint.
What carries over from #135:
conductor_run_events(run_id, filters?)— query the run's.events.jsonlby agent, event type, time range, iteration. Replaces the manual JSONL grepping documented in bug: intermittent startup crash — process dies between agent_started and first prompt render #116.conductor_node_detail(run_id, agent_name)— inputs, outputs, rendered prompt, tool calls for a node.conductor_plan_tree(workflow)— the logical structure (agents, routes, parallel/for-each groups) without running anything.conductor_list_runs(filters?)— Feature: Conductor developer toolkit — authoring skill kit + MCP server for log/state introspection #135's "list/search sessions", served from the run index (gap 1).What does not carry over: #135's first half — the authoring skill kit (scaffolds, validation skills, dry-run helpers). That is content, not protocol, and it is genuinely a different piece of work. It should be re-filed as its own issue if still wanted; the bundled
plugins/conductor/skills/conductor/has since covered part of it.What is added beyond #135, specifically for "issues triggering":
conductor_doctor(provider?)— a direct wrapper overproviders/diagnostics.py::gather(), which already returns structuredto_dict()-able data. Answers "why did this fail before it started": missing credentials, unreachable provider, unknown model, disabled reasoning effort.conductor_validate_workflow(name)—conductor validateas a tool, so an agent can check a workflow before burning a run on it, including plugin-source and skill resolution.conductor_run_logs(run_id, stream?, tail?)— returns the--web-bgcapture logs (stderr_log/stdout_log, recorded in the PID file per PID files always record an empty run_id and log_file, soconductor status --jsonships two permanently dead fields #404 and stamped intoworkflow_startedsystem metadata). This is the artifact that explains a launch that died before the engine ever emitted an event, and nothing in Feature: Conductor developer toolkit — authoring skill kit + MCP server for log/state introspection #135 covered it.F. Provenance: hash-stamp and pin at session start
Decision. At startup the server resolves every exposed workflow to an immutable identity — for GitHub registries the already-resolved commit SHA (
registry/index.py::load_indexresolves refs to SHAs precisely so this is possible), for path registries a content hash of the YAML. That identity is:Rug-pull — tool definitions changing after the user approved them — is the documented MCP attack. Conductor is unusually exposed to it because a registry-sourced workflow is remote, user-authored content that can contain
type: scriptshell steps.Gaps in Conductor this exposes
Gap 1 — no durable run registry keyed by
run_id(in scope; hard prerequisite for async)When a bg run finishes, its process exits: the dashboard is gone,
/api/stateis unreachable, and the PID file is stale. The only surviving record is$TMPDIR/conductor/conductor-<name>-<ts>-<runid>.events.jsonl— found by globbing a temp directory the OS may reap. Soconductor_run_statuson a completed run has no answer today, which is exactly the common case (agent starts a run, comes back later).Proposed shape. A small append-only run index under
~/.conductor/runs/— the directoryrundir.py::runs_dir()already owns, alongside the PID files and dashboard token files, so it inherits the same home-isolation seam tests already use. One record per run:run_id, workflow name + registry + content hash (decision F), start/end timestamps, terminal status, the renderedoutput:dict, cost, and the paths to the events JSONL and the bg capture logs. Written byengine/event_log.py::EventLogSubscriberon terminal events, so no new plumbing in the engine.conductor_run_status/conductor_await_runread live state from/api/infowhen the port is up and fall back to the index once it isn't — one code path, two sources.conductor_list_runsandconductor_run_logsread it directly.Kept in this issue rather than split out: it has no independent consumer today, and specifying it apart from the lifecycle that needs it invites a shape that doesn't fit. It improves
conductor statusand post-hoc debugging as a side effect.Gap 2 —
output:is untyped, so there is nooutputSchema(partially deferred, stated honestly)WorkflowConfig.outputisdict[str, str]— Jinja templates, verified atconfig/schema.py:3614. There is no declared type for any output key, so MCP'soutputSchemacannot be published faithfully.v1 behaviour: results are returned as
structuredContent(the renderedoutput:dict, which is structured JSON) plus a text-block fallback, with nooutputSchema. This is spec-legal and is what most servers do; the cost is that the model discovers the result shape by reading it rather than by contract.Follow-up, on this issue: derive
outputSchemafrom the referenced agents'OutputFieldtypes, which are typed (dict[str, OutputField],config/schema.py:1258), falling back tostringfor any key whose provenance can't be traced. Or add explicit types tooutput:. Shares machinery with #230.This is the one place the feature under-delivers against "it gets the inputs and outputs" — inputs are contractual in v1, outputs are structured-but-undeclared.
Gap 3 — the registry index doesn't carry input schemas (the practical blocker)
registry/index.py::WorkflowInfois{description, path}— verified. It has noinputfield. So the mapping-table line "the registry is the tool list" is only two-thirds true: the registry gives names and descriptions, and everyinputSchemarequires resolving, fetching, and parsing the workflow YAML itself.Why this matters more than it sounds: MCP stdio servers are respawned constantly by hosts (every session, every config reload, every restart). A GitHub-backed registry of 20 workflows would mean 20 HTTP fetches on every spawn before the server can answer
tools/list. Worse, a workflow YAML may not even load standalone —!fileincludes and${VAR}interpolation are resolved relative to a context the server may not have.Resolution, in order:
input:(and optionalmcp:) toWorkflowInfo, so a registry can publish its tool schemas directly. Backwards-compatible — the field is optional and absent indexes keep working. Registry authors get aconductor registry build-indexstyle helper that populates it from the YAML.registry/cache.py::get_cache_base()($CONDUCTOR_HOME/cache/registries/), keyed by the resolved commit SHA. A SHA-keyed entry is immutable, so a warm cache makes startup a no-network operation.objectinput schema and a description saying so, rather than vanishing from the tool list.Without at least (2), the feature will feel broken — slow, network-dependent startup on a surface hosts restart aggressively.
Gap 4 — descriptions are attack surface
workflow.descriptionflows into a tool description the host model reads. A workflow from a git-backed registry is user-controlled remote content. Needs sanitization (strip control characters, prompt-injection-shaped markers, nested instructions) and a hard length cap before it reaches the schema.Security
The threat model is unusually sharp because Conductor workflows contain
type: scriptshell steps and can be fetched from git registries. An MCP server that runs arbitrary user-authored workflows is an RCE surface wearing a tool schema.run_workflow(path)tool over arbitrary local paths. Registry- or allowlist-scoped only.--workflow-dir(decision A) is a startup argument the user typed, never a tool parameter the model can supply — the distinction is the whole control.conductor registry add. It never auto-discovers a registry.description(gap 4).readOnlyHint,destructiveHint) are hints, not guarantees — useful for host consent prompts, not a control.--allow/--denyscoping beats YAML, so an operator can constrain a registry they don't own.diagnosetoolset returns log contents. Spill files and tool output can contain secrets (runtime.tool_outputspill files are explicitly documented as such), soconductor_run_logsmust apply the same redaction the dashboard does, and must be tail-bounded.Relevant: MCP tool-poisoning / rug-pull CVEs (⚠️ identifiers surfaced via search summary, not verified against NVD — verify before citing in docs), Trail of Bits
mcp-context-protector, Microsoft MCP security best practices.Spec + SDK reality check
Current spec is
2026-07-28, not2025-11-25(spec, changelog). Major break: stateless protocol (noinitializehandshake), newserver/discover, HTTP GET stream replaced bysubscriptions/listen, MRTR replaces all server-initiated requests, Tasks moved to an extension repo, SSE deprecated, all results carryresultType.pyproject.tomldeclaresmcp>=1.28.1with no upper bound;uv.lockpins1.28.1; PyPI's latestmcpis2.0.0. Any lock refresh can jump major versions. SDK 2.x restructured substantially (FastMCP→MCPServer,mcp_typessplit out, logging helpers deprecated), and the existing MCP client (src/conductor/mcp/manager.py, importingmcp.client.stdio) is in the blast radius — this can break MCP tool support today with no server work involved at all.Action: add an upper bound to the
mcpconstraint. One line, do it first, independent of everything else here.Recommendation: target SDK 1.x for v1 so the server ships without a client migration riding along. The async design is unaffected either way —
run_id-based start/await is our own tool surface, not a protocol feature. The 2.x /2026-07-28move is a follow-up on this issue.Suggested scope for v1
Everything needed lives in this issue, including the groundwork it depends on. Neither piece of groundwork has an independent consumer today, so both are sequenced here rather than split out.
mcpdependency (pyproject.toml). One line, no dependencies on anything else, protects the existing client. Do it first.~/.conductor/runs/, keyed byrun_id, written byEventLogSubscriberon terminal events (gap 1). Independently testable viaconductor status.input:extension + SHA-keyed parse cache (gap 3). Independently useful — makesconductor registry list <name>richer too.conductor mcp serveover stdio, serving all registered registries by default,--registry/--allow/--deny/--workflow-dirscoping (decision A).mcp:schema block withexpose: truedefault (decision B);input:→inputSchema; sanitized, length-capped descriptions (gap 4); hash-stamping (decision F).workflow_started;_wait_secondson every tool;conductor_await_run/conductor_run_status/conductor_cancel_run/conductor_list_runs. Dashboard URL in every mode.workflows+runsdefault;--max-direct-toolswith automatic discovery fallback; startup warning near known client caps.diagnosetoolset:conductor_doctor,conductor_validate_workflow,conductor_run_logs(with redaction).introspecttoolset (absorbed Feature: Conductor developer toolkit — authoring skill kit + MCP server for log/state introspection #135):conductor_run_events,conductor_node_detail,conductor_plan_tree.structuredContent+ text fallback;resource_linkfor large logs; progress notifications from the existing event bus.status: awaiting_gate+ approval URL (reuse the dashboard gate + token model).Deferred to follow-ups on this issue, not separate ones:
outputSchema(gap 2, blocked on typingoutput:); Streamable HTTP + OAuth; SDK 2.x /2026-07-28migration; MCP Tasks (maprun_id→taskIdwhen hosts ship it); elicitation-based gates.Re-file separately if still wanted: #135's authoring skill kit half (scaffolds, validation skills, dry-run helpers) — content, not protocol, and partly covered by the bundled
conductorskill since.Suggested PR split: (0) the pin → (1) run index → (2) index extension + cache → (3–6) the server → (7–8) the toolsets → (9–10) result shape and gates.
Prior art
Cross-references
introspectanddiagnosetoolsets (decision E). Its authoring-skill-kit half is out of scope and can be re-filed.output:benefits both (gap 2).type: mcpstep for direct MCP tool calls #392 —type: mcpstep (Conductor calling MCP tools deterministically). The mirror image of this issue.conductor_run_events+conductor_run_logsare the structured answer.conductor status --jsonships two permanently dead fields #404 / --web-bg reports success and prints a URL for workflows that never start #410 — bg run id in the PID file, and confirmed-start probing. Both are directly reused (decision D, gap 1).