Skip to main content

langgraph-spec-toolkit

CI License: MIT Python 3.11+ Status: v0.2 alpha

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

Demo: init_project, apply_changes, validate_graph, and render_python run end to end, producing a deterministic graph.py

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

Why

  • Real-world cost. Measured on a real Claude Code session's /cost output (not a synthetic estimate), in a fresh session with no prior history: building a small 2-node graph from scratch cost $0.1267 hand-writing graph.py directly, vs. $0.1291 through this toolkit's MCP tools (using apply_changes to 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.yaml changes 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 generatedrender_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_, not from — the latter is a reserved word in Python. It still round-trips through the from: key in spec.yaml.

Note: the mutating tools (add_node, add_edge, remove_node, remove_edge, set_state_schema, apply_changes) return a compact summary (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. Call get_spec when you actually need the full picture.

Prefer apply_changes over 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_point is set automatically (the first node added, or entry_point: true on a later add_node op) — don't add an edge from "START" yourself, even though rendered graph.py contains one; that edge is derived from entry_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 a paths target that isn't a real node id (or END).
  • State/id typos — duplicate node ids, duplicate state field names, an entry_point that doesn't match any node id, an unknown checkpointer type.
  • Unsafe identifiersconfig.function, a conditional edge's condition, a state field's name, and a non-builtin reducer are all spliced into the generated Python unquoted (e.g. nodes.<function>), so each must be a valid Python identifier; a state field's type must 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 in graph.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):

  1. On pypi.org, add a trusted publisher for this project: owner mkrishna-gs, repo langgraph-spec-toolkit, workflow publish.yml, environment pypi. (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.)
  2. 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:

  1. Bump version in pyproject.toml.
  2. Tag and push, then publish a GitHub Release from that tag (or use gh release create).
  3. publish.yml builds 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

MIT

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)

Source distribution for langgraph-spec-toolkit 0.2.0
File Size Uploaded
langgraph_spec_toolkit-0.2.0.tar.gz 257.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for langgraph-spec-toolkit 0.2.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page