langgraph-spec-toolkit
An MCP server + Claude skill for building LangGraph projects by editing a structured YAML spec — not by regenerating Python from scratch on every turn.
edit spec.yaml (via MCP tools) → validate_graph → render_python → graph.py
Graph topology — nodes, edges, state schema, checkpointer — is data, not
prose. An LLM agent should be able to add a node or rewire an edge with one
small, targeted tool call, not re-emit 150 lines of Python and hope nothing
upstream broke. spec.yaml is the source of truth; graph.py is a
deterministic, regenerable build artifact you never hand-edit.
Quick look
Four tool calls, zero hand-written Python for the graph wiring itself.
(Regenerate with vhs .github/assets/demo.tape — see that file for a
vhs 0.12.0 bug you may need to work around.)
Text transcript, if the GIF doesn't load
$ uv run python .github/assets/demo.py
1) init_project - scaffold spec.yaml, nodes.py
2) apply_changes - nodes + edges wired in one round trip
3) validate_graph - catch problems before any code is emitted
ok=True issues=0
4) render_python - deterministic codegen, no LLM involved
wrote demo_graph/graph.py
$ cat demo_graph/graph.py
"""Auto-generated by langgraph-spec-toolkit — DO NOT EDIT BY HAND.
Regenerate with the `render_python` MCP tool after changing spec.yaml.
Source spec: demo_graph
"""
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
from . import nodes
class GraphState(TypedDict):
pass
def build_graph():
workflow = StateGraph(GraphState)
workflow.add_node('greet', nodes.greet)
workflow.add_node('respond', nodes.respond)
workflow.add_edge(START, 'greet')
workflow.add_edge('greet', 'respond')
workflow.add_edge('respond', END)
return workflow.compile()
Table of contents
- Quick look
- Why
- Installation
- Usage
- The spec format
- MCP tools
- Validation
- Example
- Development
- Releasing
- Contributing
- License
Why
- Real-world cost. Measured on a real Claude Code session's
/costoutput (not a synthetic estimate), in a fresh session with no prior history: building a small 2-node graph from scratch cost $0.1267 hand-writinggraph.pydirectly, vs. $0.1291 through this toolkit's MCP tools (usingapply_changesto wire nodes/edges in one call) — roughly at parity for this small, from-scratch case, which is close to the toolkit's least favorable scenario since there's no existing complexity yet for hand-written regeneration to be expensive. - Error rate. Free-form Python regeneration risks silently dropping an edge, mistyping a state key, or producing an unreachable node. A structured spec can be validated before any code is emitted.
- Diffability.
spec.yamlchanges are small, reviewable diffs. A regenerated file's diff is often the whole file.
Installation
Requires Python 3.11+ and uv (which provides
uvx).
Via uvx (recommended — no clone, no local install; uvx fetches
langgraph-spec-toolkit
from PyPI and runs it on demand):
{
"mcpServers": {
"langgraph-spec-toolkit": {
"command": "uvx",
"args": ["langgraph-spec-toolkit"]
}
}
}
From source (if you're developing on the toolkit itself):
git clone <this-repo>
cd langgraph-spec-toolkit
uv sync
{
"mcpServers": {
"langgraph-spec-toolkit": {
"command": "uv",
"args": ["run", "--directory", "/path/to/langgraph-spec-toolkit", "python", "-m", "mcp_server.server"]
}
}
}
Runtime dependencies are intentionally minimal: mcp, jinja2, pyyaml.
render_python's output imports langgraph (and langchain-core, if
your state uses message types) — those are dependencies of the project
you're generating, not of this toolkit.
Usage
Either config above starts the MCP server (it speaks MCP over stdio) the moment your client connects — there's no separate "run the server" step to do by hand. If you want to smoke-test it directly:
uv run python -m mcp_server.server # from a source checkout
uvx langgraph-spec-toolkit # from PyPI
Then drive it through the tools below — or point Claude at skill/SKILL.md
and let it drive itself. A typical session:
init_project(project_dir="my_graph", name="my_graph")
apply_changes(project_dir="my_graph", operations=[
{"op": "add_node", "id": "start"},
{"op": "add_node", "id": "respond"},
{"op": "add_edge", "from_": "start", "to": "respond"},
{"op": "add_edge", "from_": "respond", "to": "END"},
])
validate_graph(project_dir="my_graph") # -> ok: true
render_python(project_dir="my_graph") # -> writes my_graph/graph.py
...then write start/respond in my_graph/nodes.py and you have a
runnable graph.
The spec format
spec.yaml:
name: simple_chatbot
entry_point: greet
state:
- name: messages
type: list[BaseMessage]
reducer: add_messages
default: []
nodes:
- id: greet
type: python
config:
function: greet # callable in nodes.py; defaults to the node id
- id: chatbot
type: python
config:
function: chatbot
- id: tools
type: python
config:
function: call_tools
edges:
- from: greet
to: chatbot
- from: chatbot
condition: route_after_chatbot # router fn in nodes.py
paths:
continue: tools
end: END
- from: tools
to: chatbot
checkpointer:
type: none # none | memory | sqlite | postgres
Node and router bodies are not generated — render_python only owns
topology, state, and wiring. You write the callables in the project's
nodes.py, named to match config.function / condition. This keeps
codegen deterministic: the same spec always renders to the same Python, and
business logic never gets silently rewritten on a regen.
type on a state field is a raw Python type expression. A handful of
common symbols — BaseMessage, AnyMessage, HumanMessage, AIMessage,
SystemMessage, ToolMessage, ChatMessage, plus Any / Optional /
Sequence / Union / Literal from typing — are recognized by name and
auto-imported in the rendered file. reducer similarly recognizes
add_messages and add / operator.add as built-ins; anything else is
assumed to be a function you define in reducers.py.
MCP tools
| Tool | Purpose |
|---|---|
init_project(project_dir, name, state_fields?) |
Scaffold spec.yaml, nodes.py, __init__.py. |
add_node(project_dir, id, type?, config?, entry_point?) |
Add/update a node. The first node added becomes entry_point automatically. |
add_edge(project_dir, from_, to?, condition?, paths?) |
Add a simple (to) or conditional (condition + paths) edge. |
remove_node(project_dir, id) |
Remove a node; cascades to delete edges touching it. |
remove_edge(project_dir, from_, to?) |
Remove edge(s) from a source, optionally to one target. |
set_state_schema(project_dir, fields) |
Replace the state schema wholesale. |
apply_changes(project_dir, operations) |
Apply several add_node/add_edge/remove_node/remove_edge/set_state_schema edits in one call — atomic (nothing written if any operation is invalid). |
get_spec(project_dir) |
Read-only fetch of the full current spec. |
validate_graph(project_dir) |
Run static checks; returns ok + a list of issues. |
render_python(project_dir, output_path?) |
Emit graph.py (default: <project_dir>/graph.py). Blocks on validation errors. |
Note: edges use the parameter name
from_, notfrom— the latter is a reserved word in Python. It still round-trips through thefrom:key inspec.yaml.
Note: the mutating tools (
add_node,add_edge,remove_node,remove_edge,set_state_schema,apply_changes) return a compactsummary(node/edge/state counts, entry point, checkpointer type) rather than the full spec — echoing the whole graph back on every small edit would grow with graph size and quietly erode the token savings this toolkit exists for. Callget_specwhen you actually need the full picture.
Prefer
apply_changesover separate calls whenever wiring more than one node/edge at once (e.g. a whole tool-calling loop) — it's the same edit, one round trip instead of several. See Why for the measured real-world cost. Each operation is a dict with an"op"key plus that operation's normal arguments, e.g.{"op": "add_node", "id": "tools", "config": {...}}— see the tool's own description for the full list.entry_pointis set automatically (the first node added, orentry_point: trueon a lateradd_nodeop) — don't add an edge from"START"yourself, even though renderedgraph.pycontains one; that edge is derived fromentry_point, not wired as a spec edge.
Validation
validate_graph checks for:
- Unreachable nodes — no path from
entry_point. - Missing path to
END— a node that can never terminate the graph. - Dangling conditions — a conditional edge with no
paths, or apathstarget that isn't a real node id (orEND). - State/id typos — duplicate node ids, duplicate state field names, an
entry_pointthat doesn't match any node id, an unknown checkpointer type. - Unsafe identifiers —
config.function, a conditional edge'scondition, a state field'sname, and a non-builtinreducerare all spliced into the generated Python unquoted (e.g.nodes.<function>), so each must be a valid Python identifier; a state field'stypemust at least parse as a Python expression. This is a correctness and safety check — it's the boundary that keeps a bad spec value from becoming arbitrary code ingraph.py.
render_python refuses to emit code while validation errors are present;
warnings (like an unreachable node) don't block rendering.
Example
examples/simple_chatbot has a spec with a
message-reducer state field, a linear edge, and a conditional tool-call
loop, plus the generated graph.py — diff the two to see exactly what
codegen does. It's been exercised end-to-end against a real langgraph +
langchain-core install to confirm the generated wiring executes, not just
that it parses.
Development
uv sync
uv run python -m mcp_server.server # smoke-test the server starts
uv run pytest # run the test suite
uv run ruff check . # lint
The test suite (tests/) covers spec.py (dataclasses, YAML round-trips),
validator/ (every check, including the identifier/injection-safety ones),
renderer/ (codegen against the committed example, plus each reducer/
checkpointer variant), every MCP tool's run() function, and MCP tool
registration itself. New tools or spec fields should come with tests in
the matching file.
CI (.github/workflows/ci.yml) runs lint and the test suite (on Python
3.11 and 3.12) on every push and pull request against main.
Releasing
Publishing to PyPI (.github/workflows/publish.yml) uses
Trusted Publishing — no API
token is stored in this repo. One-time setup (maintainers only):
- On pypi.org, add a trusted publisher for this
project: owner
mkrishna-gs, repolanggraph-spec-toolkit, workflowpublish.yml, environmentpypi. (If the project doesn't exist on PyPI yet, PyPI supports adding a trusted publisher for a not-yet-published project name — it claims the name on first publish.) - In this repo's GitHub settings, create an environment named
pypi(optionally with required reviewers, for an extra manual gate before every publish).
After that, cutting a release is the whole process:
- Bump
versioninpyproject.toml. - Tag and push, then publish a GitHub Release from that tag (or use
gh release create). publish.ymlbuilds the sdist/wheel and publishes them automatically.
Contributing
Issues and pull requests are welcome — see
CONTRIBUTING.md for the project layout, dev setup, and
the checklist to run through before opening a PR.
License
Release files for langgraph-spec-toolkit 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| langgraph_spec_toolkit-0.2.0.tar.gz | 257.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langgraph_spec_toolkit-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 285.2 kB
Release files / langgraph_spec_toolkit-0.2.0.tar.gz
| Download URL | langgraph_spec_toolkit-0.2.0.tar.gz |
|---|---|
| Size | 257.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c3c8d226edd526d361cfef79feba257a3265944093f358e48cea829440cab00c
|
|
BLAKE2b-256 checksum How to use checksums |
c6e054e4f190075f3164f8fe02472b2ee1a82d256c5845e3a34feefef88b2732
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.
Transparency logRelease files / langgraph_spec_toolkit-0.2.0-py3-none-any.whl
| Download URL | langgraph_spec_toolkit-0.2.0-py3-none-any.whl |
|---|---|
| Size | 28.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fce183729e662117220c04706ffec10f8a5797b2cf9a64f6cca3c6c6eb75ff5b
|
|
BLAKE2b-256 checksum How to use checksums |
82da91553ae6e6915c3a40cc73322ab908eb2aed2e068895bade2f455964224a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.
Transparency log