The Agent (MCP) Tool Surface¶
Cursus ships its ML-pipeline engine three ways. Humans drive it through the
CLI and the Python API; agents drive it through the
cursus.mcp tool surface — a framework-neutral set of small, typed, JSON-in / JSON-out
tools that any agent framework (an MCP client, OpenAI function-calling, Claude tool_use,
Bedrock) can call. All three front ends sit on top of the same engine (core.compiler,
api.dag, step_catalog, validation, …), so the tool surface is not a parallel
implementation — it is a third adapter over the same capabilities.
This page explains that surface: the registry that declares every tool, the result
envelope every tool returns, the 12 tool namespaces, the self-documenting help design,
the optional MCP server adapter and its exporters, and the shipped dynamic-workflow
orchestrators plus the Kiro runtime that runs them.
Why a tool surface¶
An agent cannot import Python modules, read tracebacks, or hold a live PipelineDAG
object in memory the way a script can. It needs capabilities that are:
discoverable — it can ask “what can I do?” and “how do I call this?” in-band;
uniform — every call returns the same success/error shape, so the agent’s control loop never has to special-case a stack trace or a printed message;
safe — a tool call never crashes the caller; state-mutating tools declare their side effects, and the public server is read-only by default (mutation is opt-in);
portable — the same tool definitions feed an MCP server, an OpenAI tool list, and a Claude tool list without rewriting.
cursus.mcp provides exactly this. The package (src/cursus/mcp/__init__.py) is designed
to sit parallel to cursus.core / cursus.steps and explicitly does not replace
cursus.cli — the human-facing CLI and the agent-facing tools are siblings over one engine.
The envelope: ToolResult / ToolError¶
Every tool returns a ToolResult (src/cursus/mcp/envelope.py) — a small,
JSON-serializable success/error record. Tools never raise across the tool boundary and
never print; the envelope is the entire contract, so any agent runtime can consume the
outcome uniformly.
from cursus.mcp import call_tool
result = call_tool("catalog.search", {"query": "xgboost"})
result.ok # True / False
result.data # JSON-serializable payload (on success)
result.error # human-readable message (on failure)
result.code # machine-readable code, e.g. "not_found", "invalid_input"
ToolResult fields:
Field |
Meaning |
|---|---|
|
Whether the tool succeeded. |
|
JSON-serializable payload on success ( |
|
Human-readable error message on failure. |
|
Machine-readable error code ( |
|
Non-fatal messages the agent may surface or reason about. |
|
On success: in-band |
|
On failure: |
|
Side-band info (tool name, timings, counts) — never required for correctness. |
Two constructors keep the shape consistent: ToolResult.success(data, warnings=…, next_steps=…, **meta) and ToolResult.failure(message, code=…, details=…, remedy=…).
to_dict() renders a compact wire form that drops empty optional fields.
ToolError is the handled-failure signal a tool raises internally (bad input,
not-found, validation). The registry’s invoker catches it and turns it into
ToolResult.failure(...), preserving code and details. Genuinely unexpected
exceptions are allowed to propagate so the invoker can wrap them as an internal_error
envelope — see call_tool below.
The registry: ToolDef and friends¶
src/cursus/mcp/registry.py is the single source of truth. Every tool is one
declarative ToolDef (a frozen dataclass), and the whole registry is a map keyed by the
tool’s dotted name ("catalog.list_steps").
@dataclass(frozen=True)
class ToolDef:
name: str # dotted, unique, namespaced: "compile.dag"
description: str # what + when, for the agent
schema: Dict[str, Any] # JSON Schema (draft-07 object) for the arguments
handler: ToolHandler # (validated arg dict) -> ToolResult
destructive: bool = False # mutates external/persistent state (SageMaker upsert)
tags: tuple = () # lifecycle phase(s): "planner" / "validator" / "programmer"
when: str = "" # one-line "call this when …" trigger cue
examples: tuple = () # copy-paste invocation strings
writes: bool = False # writes to the local filesystem
exec_code: bool = False # runs arbitrary code / installs packages
network: bool = False # reaches the network / AWS (open-world)
@property
def namespace(self) -> str: # leading segment of name → "compile"
return self.name.split(".", 1)[0]
@property
def wire_name(self) -> str: # host-legal name: "compile.dag" → "compile__dag"
return self.name.replace(".", "__")
The four capability flags (destructive, writes, exec_code, network) are what let the
server run read-only by default and attach MCP tool annotations — see
Safety: read-only by default. wire_name exists because host
tool-calling APIs reject the . in names — see MCP server adapter.
Three fields are what make the surface self-teaching:
when— the trigger condition (“call this when …”), complementing the what indescription.examples— real, copy-paste invocation strings, e.g.'catalog.list_steps {"job_type": "training"} # only training-variant steps'. Stored as a tuple so the frozen dataclass stays truly immutable.tags— the lifecycle phase(s) a tool belongs to, so an agent can route by phase instead of scanning every description.
Registry assembly¶
The registry is built once and cached. _build_registry() imports each namespace module
listed in _TOOL_MODULES, collects its module-level TOOLS: List[ToolDef], and derives
the namespace key from the tools themselves (the leading segment of td.name), not the
filename — for example info.py defines the tools.* namespace. A namespace that fails to
import (say an optional engine dependency is missing) is logged and skipped rather than
breaking the whole registry. Each module also exposes a one-line NAMESPACE string that
becomes the namespace’s description.
Public accessors:
Function |
Returns |
|---|---|
|
The canonical |
|
|
|
All |
|
One |
Invocation: call_tool¶
call_tool(name, args) is the in-process invoker and enforces the tool contract. It
always returns a ToolResult — an unknown tool, invalid arguments, a handled
ToolError, or an unexpected exception are all converted to error envelopes, so a tool
call can never crash the caller:
Unknown name →
ToolResult.failure(code="unknown_tool")with the available names indetails.Light JSON-schema validation via
_validate_args— checks required keys, unknown keys (whenadditionalPropertiesisFalse), and top-levelenummembership. This is deliberately not a full validator; it gives the agent fast, clear feedback on obvious mistakes, while the handler remains the final authority. Failure →ToolResult.failure(code="invalid_input").Run the handler. A raised
ToolErrorbecomesfailure(code=te.code, …); any other exception is logged and wrapped ascode="internal_error". On success,meta["tool"]is stamped with the tool name.
The 12 namespaces¶
Tools are grouped into 12 dotted namespaces, each declared by one module under
src/cursus/mcp/tools/. The namespace descriptions below are the exact NAMESPACE
strings the registry collects.
Namespace |
Purpose |
Representative tools |
|---|---|---|
|
Discover/search steps, configs, and builders ( |
|
|
Construct, validate, and serialize pipeline DAGs ( |
|
|
Schema-driven config generation and loading ( |
|
|
Compile/validate/preview a DAG into a SageMaker pipeline ( |
|
|
Alignment, dependency, and script-execution checks ( |
|
|
MODS execution-document generation and validation ( |
|
|
Recommend/select/load pre-built shared DAGs ( |
|
|
Scaffold a new Cursus pipeline project (phase-0 skeleton + action-item ledger). |
|
|
Inspect the builder strategy library — axes and knobs ( |
|
|
Per-step connection/I-O view: container paths, property refs, channels. |
|
|
Guardrails + preflight checks for authoring a new step (rules, checklist, preflight). |
|
|
Meta/discovery over the tool registry itself (help, by_phase, describe_tool). |
|
(The tools namespace is defined in info.py; the module filename and the namespace key
differ on purpose.) The generated MCP tools reference
lists every tool with its full schema.
Lifecycle phases¶
Every tool is tagged with one or more lifecycle phases (info.py), which is how an agent
routes work rather than reading dozens of descriptions. The three phase names are the same
planner / validator / programmer taxonomy the agentic-workflow design assigns to its
three cooperating agents — the tool tags let a single agent (or three specialized ones)
route work by role instead of scanning every tool:
Phase |
Intent |
|---|---|
|
Discover, select, and assemble — explore steps/configs/DAGs and plan the pipeline. |
|
Check before building — alignment, dependencies, config/DAG integrity, scripts. |
|
Compile and generate — turn a resolved DAG into a pipeline / execution doc. |
These intents are the exact _PHASE_DESCRIPTIONS strings tools.help reports. A typical
agent run flows PLAN → VALIDATE → PROGRAM: discover steps and assemble a DAG, check
alignment/dependencies/integrity, then compile the DAG into a SageMaker pipeline and
generate the execution document. The phase tags are not just documentation — tools.by_phase
filters the live registry by tag, so “give me the validator tools” is a single query.
Safety: read-only by default¶
A public MCP server is an LLM-reachable RPC surface, so the shipped server is read-only by default. Every tool declares its side effects with the capability flags above, and the server refuses to expose (or run) a side-effecting tool unless the operator opts in with an environment variable:
Env var |
Enables |
Tools |
|---|---|---|
(none — the default) |
the read-only surface (~66 tools) |
discovery, inspection, validation-analysis, help |
|
filesystem writes + AWS upserts ( |
|
|
running step scripts locally, which may |
|
|
confinement root for |
— |
The gate is enforced twice: gated tools are omitted from list_tools, and rejected at
call_tool (a client cannot reach a hidden tool by guessing its name — it gets a
tool_disabled error). project.init additionally confines its writes: name may not
contain path separators, target_dir may not contain .., and under CURSUS_MCP_PROJECT_ROOT
the resolved write path may not escape that root. In-process / CLI callers (which are trusted)
are unaffected by the gating; it applies to the MCP server surface.
Self-documenting design¶
The surface teaches itself, so an agent never has to be pre-loaded with the whole tool list. Three layers cooperate.
tools.help — the front door¶
tools.help (info.py) introduces the entire toolset in one call: a short overview,
the three lifecycle phases with counts, and every tool grouped by namespace with its
one-line description. Optional filters narrow the output without changing its shape:
tools.help {} # full overview of every namespace and tool
tools.help {"namespace": "compile"} # just the compile.* tools
tools.help {"phase": "validator"} # every validator-phase tool
tools.help {"namespace": "dag", "include_schema": true} # dag tools + JSON input schemas
The result also carries next_steps pointing at <namespace>.help,
tools.describe_tool, and tools.by_phase — the golden discovery path is baked into the
payload.
Auto <ns>.help — per-namespace overviews¶
The registry auto-generates a <namespace>.help tool for every namespace that
declares a NAMESPACE description (via _make_namespace_help_tool). So an agent working
in the compile space can call compile.help and get just the compile overview plus its
tools, without scanning the whole surface. Each generated help delegates to tools.help
with namespace pinned, so there is exactly one rendering path. The meta tools
namespace is excluded (_NO_AUTO_HELP) because its hand-written tools.help is already
the global front door, and a hand-written <ns>.help is never overwritten.
tools.by_phase and tools.describe_tool¶
tools.by_phase(phase)returns the tools tagged for one lifecycle phase — turning tool selection from “read 50+ descriptions” into a single filtered query.tools.describe_tool(name)returns one tool’s full descriptor: description,whencue,examples, JSON input schema, phasetags, and thedestructiveflag.
Because this namespace reads only the live registry and has no engine dependencies, it never fails to import and is cheap to call.
Folding guidance into a single description¶
External MCP / OpenAI clients only receive one description string per tool — they cannot
see the structured when / examples fields. render_description(td) folds those in:
<description>
When: <when>
Examples:
- <example 1>
- <example 2>
In-process agents get the fields structured (via help/describe); external agents get the same guidance concatenated into the description, so nobody misses the usage cues.
The server and exporters¶
Because the registry is the single source of truth, the same ToolDefs generate every
external descriptor shape.
MCP server adapter¶
src/cursus/mcp/server.py is a thin adapter that mounts the framework-neutral tools onto
an actual Model Context Protocol server. The official mcp Python SDK is an optional
dependency, imported lazily, so importing cursus.mcp (the tools, schemas, and registry)
never requires the SDK — only the running server does. If the SDK is absent, a clear,
actionable error points at the extra to install.
python -m cursus.mcp.server # run the cursus tools as a stdio MCP server
build_server(name="cursus") wires the server’s list_tools from the registry and routes
call_tool straight through registry.call_tool, so the MCP server and in-process
callers share exactly one code path and one result contract. Four details make that surface
host-safe and host-usable:
Host-legal names. Each listed tool is exposed under its
wire_name(compile.dag→compile__dag). MCP hosts forward tool names to the Anthropic / OpenAI tool-calling APIs, whose name pattern (^[a-zA-Z0-9_-]{1,64}$) rejects the.; the dotted names would fail the moment a model invoked one. Incoming calls resolve back to the dotted name viaget_tool, and human-facing help keeps the readable dotted form.Tool annotations. Each
types.ToolcarriesToolAnnotations(readOnlyHint/destructiveHint/openWorldHint) derived from the capability flags, so a host can auto-approve the read-only tools and prompt before the mutating ones.isErrorsignaling. A failed call returns aCallToolResultwithisError=True(rather than a success envelope withok:falseburied in text), so hosts and models can tell failure from success at the protocol level.Correct
serverInfo+ clean stdout. The server reportscursus.__version__(not the SDK’s), and reserves stdout for JSON-RPC framing (logs go to stderr) so nothing corrupts the stream. Install the SDK withpip install "cursus[mcp]"(needsmcp>=1.2.0); thecursus-mcpconsole script,python -m cursus.mcp.server, andcursus mcp serveare equivalent launchers. See the MCP server README for host wiring (Claude Desktop / Cursor / Kiro).
Framework exporters¶
Two pure functions generate tool lists for other agent frameworks from the same registry:
Exporter |
Output shape |
|---|---|
|
OpenAI / Claude function-calling shape — |
|
MCP |
Both emit the host-legal wire_name (dots → __) as name, and fold the when / examples
guidance into the description via render_description, so external OpenAI/Claude/MCP clients
get a name their tool-calling API accepts and the same usage hints an in-process agent gets.
Mirroring the CLI and API¶
The CLI mirrors the tool surface directly. cursus mcp (see
src/cursus/cli/mcp_cli.py) exposes three subcommands, all of which read the same registry:
cursus mcp help # the guided overview — same data as the tools.help tool
cursus mcp help --namespace compile # zoom into one namespace (auto-shows examples)
cursus mcp help --phase validator # filter by lifecycle phase
cursus mcp list-tools # print the registered tools
cursus mcp list-tools --format json # machine-readable listing (no SDK needed)
cursus mcp serve # run the stdio MCP server (needs the optional mcp SDK)
cursus mcp help literally calls call_tool("tools.help", …) and renders the resulting
ToolResult.data, so the human overview and the agent overview can never drift. Engine and
SDK imports are lazy, so cursus --help and cursus mcp list-tools work even without the
optional mcp SDK installed.
The result: one registry, three front ends. The Python API exposes
the engine to scripts, the CLI exposes it to humans, and cursus.mcp exposes
it to agents — each tool namespace maps onto the same subsystem the CLI and API already use
(core.compiler, api.dag, step_catalog, validation, mods.exe_doc, …).
Dynamic-workflow orchestrators¶
Individual tools are primitives; real tasks are multi-step. src/cursus/mcp/workflows/
ships Claude Code dynamic-workflow scripts that orchestrate the tools into deterministic,
resumable, batch-capable sequences. They are JavaScript orchestration scripts for the Claude
Code Workflow runtime (an export const meta = {...} header plus the host-injected
agent() / parallel() / pipeline() / phase() primitives) — reference/runnable
artifacts, not importable Python. Each workflow’s phases are non-skippable pipeline
stages, so a task cannot reach its final “green” stage without passing the same gates CI
runs.
Workflow |
What it does |
|---|---|
|
Scaffold a brand-new project package (the phase-0 skeleton that is byte-identical across projects) plus an action-item ledger of what remains. |
|
Author or repair a project’s |
|
DAG-driven authoring of a new step type between a producer and a consumer, gated on spec alignment (the resolver’s 6-component compatibility score), preflight constructibility, and script/interface checks. |
|
End-to-end bring-up: composes the three workflows above so a team runs one command and ends with a compile-ready project. |
The workflows call the cursus tools as MCP tools (author.checklist,
validate.step_interface, steps.io, compile.preview, …). If the MCP server is
unreachable in the harness, the gate stages fall back to the cursus CLI (e.g.
cursus validate step-interface …) — they never fall back to hand-editing the registry,
because .step.yaml interface files are the source of truth (see
Registry & discovery). Two of these flows also have a
deterministic, offline MCP-tool equivalent: project.init scaffolds statelessly, and
project.bring_up returns the cursus-new-project invocation for a caller that wants the
whole chain. See src/cursus/mcp/workflows/README.md for the full stage-by-stage design.
The Kiro runtime¶
The workflow scripts target the Claude Code Workflow runtime, whose primitives are
injected by the Claude Code host — under a different harness the scripts are inert.
src/cursus/mcp/workflows/kiro/ is the Kiro alternative: it re-implements those same
primitives (agent / parallel / pipeline / phase / log plus the args / budget
globals) on top of kiro-cli, so the unmodified parent workflow scripts run under Kiro
as well.
Key pieces (kiro/README.md):
File |
Role |
|---|---|
|
The engine — re-implements the CC workflow primitives on |
|
A dependency-free JS ACP client (JSON-RPC 2.0 over newline-delimited stdio) that spawns and drives one long-lived |
|
The CLI — loads an unmodified CC workflow |
Because the primitive API is kept identical, one workflow file runs in both harnesses. When
Kiro reaches the MCP-tool stages, it can inject the cursus mcp serve stdio server into the
ACP session (--mcp-cursus --transport acp) so the same author.* / validate.* /
compile.* tools are reachable — the surface stays the same no matter which agent runtime
drives it.
See also¶
Registry & interface-first discovery — the step registry the
catalog.*andauthor.*tools read.DAG & compilation — the engine behind
dag.*andcompile.*.Dependency resolution — the resolver behind
validate.deps_resolve/validate.deps_explainand the edge-alignment score.MCP tools reference — every tool with its schema.
CLI reference —
cursus mcp help/list-tools/serve.