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.
Table of contents
- Why
- Prior art
- Project status
- Installation
- Usage
- The spec format
- MCP tools
- Validation
- Example
- Benchmarks
- Development
- Releasing
- Contributing
- License
Why
-
Token cost. A full-file rewrite scales with graph size on every edit; a spec edit doesn't. Measured with
benchmarks/token_usage.py(uv run python benchmarks/token_usage.py, no network access or vendor SDK required — see the script for what "token" means here):Nodes in graph Full graph.pyregen (tokens)One spec edit (tokens) Ratio 3 168 21 8.0x 5 204 21 9.7x 10 294 21 14.0x 25 564 21 26.9x 50 1014 21 48.3x 100 1914 21 91.1x A single spec edit stays flat regardless of graph size; a full-file regen grows linearly with it. Token counts use a small offline approximate tokenizer, not a specific vendor's real BPE tokenizer, so the numbers are illustrative rather than exact — the shape of the curve (flat vs. linear) is the actual claim, and holds under any reasonable way of counting.
-
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.
Prior art
langgraph-codegen already
does DSL → Python codegen for LangGraph and is worth a look. It ships as a
library/CLI, without an MCP server, a validation pass, diagramming, or a
skill layer for an LLM to drive it interactively — that's the gap this
project fills. We use our own spec format rather than adopting its DSL.
Project status
v0.1 (current, 0.1.0) — first cut, functional end-to-end on a single
flat graph:
- Spec schema: state fields, nodes, edges (simple + conditional), checkpointer.
- MCP tools:
init_project,add_node,add_edge,remove_node,remove_edge,set_state_schema,get_spec,validate_graph,render_python. - Validation: unreachable nodes, missing path to
END, dangling conditions/edges, duplicate/typo'd ids, unsafe identifiers infunction/condition/state field names/reducers. - Deterministic Jinja2 codegen — no LLM in the render path.
pytestsuite covering the spec model, validator, renderer, and every MCP tool.
Out of scope for v0.1: diagramming, subgraphs, multi-file projects, and a
langgraph-codegen-style DSL importer. This is a young project; expect the
spec schema and tool signatures to evolve before 1.0.
Installation
Requires Python 3.11+ and uv (which provides
uvx).
Via uvx (recommended once a release is published — no clone, no local
install; uvx fetches and runs it on demand):
{
"mcpServers": {
"langgraph-spec-toolkit": {
"command": "uvx",
"args": ["langgraph-spec-toolkit"]
}
}
}
From source (needed until the first PyPI release lands, or 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 # once published
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")
add_node(project_dir="my_graph", id="start", config={"function": "start"})
add_node(project_dir="my_graph", id="respond", config={"function": "respond"})
add_edge(project_dir="my_graph", from_="start", to="respond")
add_edge(project_dir="my_graph", 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. |
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) 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.
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.
Benchmarks
benchmarks/token_usage.py measures the
token-cost claim in Why: full graph.py regeneration vs. a single
spec-tool edit, across graph sizes from 3 to 100 nodes.
uv run python benchmarks/token_usage.py
No network access or vendor SDK required — see the script's docstring for what it counts as a "token" and why.
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. For anything beyond a small fix, please open an issue first to discuss scope — the spec schema and tool signatures are still settling in this pre-1.0 phase, and larger changes are easier to land as a shared plan than as a surprise diff.
Before opening a PR:
uv syncand confirmuv run python -m mcp_server.serverstarts cleanly.uv run pytestanduv run ruff check .both pass. New tools or spec fields need tests alongside them.- Keep runtime dependencies to
mcp,jinja2,pyyaml— anything else belongs in the generated project, not this toolkit (test-only deps go in[dependency-groups.dev]). - Keep
render_pythondeterministic: no LLM calls, no non-reproducible output, in the render path.
License
Release files for langgraph-spec-toolkit 0.1.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.1.0.tar.gz | 87.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langgraph_spec_toolkit-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 113.1 kB
Release files / langgraph_spec_toolkit-0.1.0.tar.gz
| Download URL | langgraph_spec_toolkit-0.1.0.tar.gz |
|---|---|
| Size | 87.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
55061190da4241c4022cb67c89d2bfe1f910bd3aa1883ad937fe367849f95f63
|
|
BLAKE2b-256 checksum How to use checksums |
16c721e5a607d383f2b6a75a30b5ba7bf3a2db85d80f9d4fd0e579b93917ca0d
|
| 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 16, 2026.
Transparency logRelease files / langgraph_spec_toolkit-0.1.0-py3-none-any.whl
| Download URL | langgraph_spec_toolkit-0.1.0-py3-none-any.whl |
|---|---|
| Size | 25.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
04f08ee78bb03187d86db3e384a6b13b94afc85c1152e13fce4404af4d61e8b7
|
|
BLAKE2b-256 checksum How to use checksums |
e05dec1d60c27dbc13c4d07d393c4d9ee5193abc66bad628cbd5a53c6d170908
|
| 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 16, 2026.
Transparency log