levain-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ """Validate ``@mcp_servers`` names resolve to mapped tools.
2
+
3
+ Run after the profile + graph stages produce the annotation
4
+ list. For each node's ``@mcp_servers`` declaration, every name
5
+ must resolve to either:
6
+
7
+ - A platform-provided MCP server (today: ``knead-review``), or
8
+ - A customer-attached integration mapped to the target agent —
9
+ surfaced via ``LEVAIN_TARGET_INTEGRATIONS_B64`` and decoded by
10
+ :mod:`levain_sdk.integrations`. Only MCP-callable kinds
11
+ count (see ``_MCP_CALLABLE_KINDS``).
12
+
13
+ The check is **deterministic over its inputs**: it consumes the
14
+ annotation list (from the spec source) and the target-integration
15
+ list (from an explicit parameter or the env var). When neither
16
+ the parameter nor the env var supplies any context, the check is
17
+ skipped — a clean checkout / manual CLI invocation has no source
18
+ of truth to validate against, and rejecting valid recipes for
19
+ lack of context would be worse than letting runtime resolution
20
+ catch the error.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Mapping
26
+ from typing import Any
27
+
28
+ from .models import NodeAnnotation, ValidationError
29
+
30
+ # Structural stand-in for ``levain_sdk.integrations.TargetIntegration``
31
+ # (a TypedDict, so ``.get()`` is all we need). Typed locally to keep
32
+ # the agent runtime out of this package's dependencies.
33
+ TargetIntegration = Mapping[str, Any]
34
+
35
+ # Platform-provided MCP servers, mirroring
36
+ # ``levain_sdk.mcp_registry._PLATFORM_SERVERS``. Vendored for the
37
+ # same reason as ``known_recipes.CALLABLE_RECIPES``: this package
38
+ # validates recipes without installing the runtime. Includes the
39
+ # platform-agent-only servers (``levain-build``, ``levain-issues``),
40
+ # which the runtime registers by side-effect import — so the live
41
+ # set differs by process, and this copy is the union. A drift test
42
+ # in Levain's CI keeps the two equal; a stale copy only affects
43
+ # local lint, since the platform re-validates every push.
44
+ PLATFORM_MCP_SERVERS = frozenset(
45
+ {
46
+ "google_ads",
47
+ "knead-review",
48
+ "levain-agents",
49
+ "levain-app-data",
50
+ "levain-artifacts",
51
+ "levain-build",
52
+ "levain-image",
53
+ "levain-issues",
54
+ "levain-memory",
55
+ "levain-progress",
56
+ "levain-sessions",
57
+ "levain-wiki",
58
+ "meta_ads",
59
+ "shopify",
60
+ "slack",
61
+ "stripe",
62
+ }
63
+ )
64
+
65
+
66
+ def platform_server_names() -> set[str]:
67
+ """Names an ``@mcp_servers`` entry can bind to platform-side."""
68
+ return set(PLATFORM_MCP_SERVERS)
69
+
70
+
71
+ def _load_env_targets() -> list[TargetIntegration]:
72
+ """Attached integrations from the sandbox env, when available.
73
+
74
+ Only resolvable inside a sandbox, where the runtime is installed
75
+ and the harness has injected the env var. Everywhere else this
76
+ is empty and the caller skips the check.
77
+ """
78
+ try:
79
+ from levain_sdk.integrations import load_target
80
+ except ImportError:
81
+ return []
82
+ return list(load_target())
83
+
84
+
85
+ # Integration kinds the runtime resolver can wire as @mcp_servers:
86
+ # OAuth-protected remote MCP servers, and GitHub App integrations
87
+ # (resolved to the SDK's bundled GitHub tools server). Mirror of
88
+ # the dispatch in ``levain_sdk.mcp_registry.resolve_mcp_servers``.
89
+ _MCP_CALLABLE_KINDS = frozenset({"mcp_url", "github_app"})
90
+
91
+
92
+ def check_mcp_names(
93
+ annotations: list[NodeAnnotation],
94
+ *,
95
+ attached_mcp_names: list[str] | None = None,
96
+ ) -> list[ValidationError]:
97
+ """Return one ``ValidationError`` per unresolved ``@mcp_servers`` name.
98
+
99
+ ``attached_mcp_names``:
100
+ - ``None`` (default): read the target list from the env
101
+ var. When the env is unset / empty, the check is
102
+ skipped (allowing platform names) so a clean-checkout
103
+ run on a recipe declaring ``["knead-review"]`` still
104
+ passes.
105
+ - explicit list (incl. ``[]``): enforce against the given
106
+ list union with platform server names. Pass ``[]`` to enforce
107
+ "platform-only" behaviour in tests.
108
+ """
109
+ targets: list[TargetIntegration]
110
+ if attached_mcp_names is None:
111
+ targets = _load_env_targets()
112
+ if not targets:
113
+ # No source of truth; skip the check rather than
114
+ # rejecting recipes that legitimately reference tools
115
+ # we just can't see from here.
116
+ return []
117
+ explicit_names: list[str] = []
118
+ else:
119
+ targets = []
120
+ explicit_names = list(attached_mcp_names)
121
+
122
+ platform = platform_server_names()
123
+
124
+ errors: list[ValidationError] = []
125
+ for ann in annotations:
126
+ servers = ann.args.get("mcp_servers")
127
+ if not isinstance(servers, list):
128
+ continue
129
+ for s in servers:
130
+ if not isinstance(s, str):
131
+ continue
132
+ if _resolves(s, platform, targets, explicit_names):
133
+ continue
134
+ errors.append(
135
+ ValidationError(
136
+ stage="mcp_servers",
137
+ message=_unresolved_message(
138
+ s, platform, targets, explicit_names
139
+ ),
140
+ )
141
+ )
142
+ return errors
143
+
144
+
145
+ def unresolved_mcp_names(
146
+ names: list[str],
147
+ *,
148
+ attached_mcp_names: list[str],
149
+ ) -> list[str]:
150
+ """Subset of ``names`` that resolve to nothing.
151
+
152
+ The per-name resolution primitive behind :func:`check_mcp_names`,
153
+ exposed for callers that carry their own integration context (the
154
+ API's graph annotation) — sharing ``_resolves`` keeps every
155
+ consumer on one definition of "this name binds at run time".
156
+ """
157
+ platform = platform_server_names()
158
+ explicit = list(attached_mcp_names)
159
+ return [n for n in names if not _resolves(n, platform, [], explicit)]
160
+
161
+
162
+ def _resolves(
163
+ name: str,
164
+ platform: set[str],
165
+ targets: list[TargetIntegration],
166
+ explicit_names: list[str],
167
+ ) -> bool:
168
+ """Mirror the runtime resolver's matching rules."""
169
+ needle = name.strip().casefold()
170
+ if any(p.casefold() == needle for p in platform):
171
+ return True
172
+ if any(
173
+ _matches(name, t)
174
+ for t in targets
175
+ if t.get("kind") in _MCP_CALLABLE_KINDS
176
+ ):
177
+ return True
178
+ return any(e.strip().casefold() == needle for e in explicit_names)
179
+
180
+
181
+ def _matches(name: str, target: TargetIntegration) -> bool:
182
+ """Mirror of ``platform_sdk.integrations.matches``.
183
+
184
+ Duplicated (not imported) because ``platform_sdk`` isn't a
185
+ dependency of the open ``levain-cli`` package — see
186
+ ``known_recipes.CALLABLE_RECIPES`` for the same pattern.
187
+ """
188
+ if not name:
189
+ return False
190
+ needle = name.strip().casefold()
191
+ display = target.get("display_name", "").strip().casefold()
192
+ if display and display == needle:
193
+ return True
194
+ provider = target.get("provider", "").strip().casefold()
195
+ return bool(provider and provider == needle)
196
+
197
+
198
+ def _unresolved_message(
199
+ name: str,
200
+ platform: set[str],
201
+ targets: list[TargetIntegration],
202
+ explicit_names: list[str],
203
+ ) -> str:
204
+ """Build a helpful error listing what *is* available."""
205
+ available_target_names = [
206
+ t["display_name"]
207
+ for t in targets
208
+ if t.get("display_name") and t.get("kind") in _MCP_CALLABLE_KINDS
209
+ ]
210
+ available = sorted({*platform, *available_target_names, *explicit_names})
211
+ if available:
212
+ listing = ", ".join(available)
213
+ else:
214
+ listing = "(none)"
215
+ return (
216
+ f"@mcp_servers name {name!r} does not resolve to a "
217
+ f"mapped integration. Available: {listing}."
218
+ )
@@ -0,0 +1,21 @@
1
+ """Recipes a ``call_agent`` node may target.
2
+
3
+ Mirrors the platform's trusted-recipe set
4
+ (``platform_sdk.recipe.names.RECIPE_NAMES``); a drift-guard test
5
+ in Levain's CI keeps the two equal. The server re-validates
6
+ every push with its own copy, so a stale local set only affects
7
+ local lint output.
8
+ """
9
+
10
+ CALLABLE_RECIPES = frozenset(
11
+ {
12
+ "build",
13
+ "commit",
14
+ "crawler",
15
+ "issue_maintainer",
16
+ "observer",
17
+ "plan",
18
+ "review",
19
+ "wiki_maintainer",
20
+ }
21
+ )
levain_recipe/mcp.py ADDED
@@ -0,0 +1,85 @@
1
+ """Stdio MCP server exposing the recipe toolchain.
2
+
3
+ For MCP clients that cannot run shell commands (e.g. Claude
4
+ Desktop): the tools mirror the CLI commands and operate on the
5
+ local filesystem. Requires the ``[mcp]`` extra.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from fastmcp import FastMCP
14
+
15
+ from ._format import format_errors, format_success
16
+ from ._java import ensure_jar
17
+ from .codegen import generate_state_module
18
+ from .validate import validate, validate_fast, validate_package
19
+
20
+ server = FastMCP("levain-recipe")
21
+
22
+ _NO_JAVA_NOTE = (
23
+ "Note: Java not found — stages 4-5 (TLC model checking) "
24
+ "skipped; they still run server-side on push.\n\n"
25
+ )
26
+
27
+
28
+ @server.tool()
29
+ def validate_recipe(path: str) -> str:
30
+ """Validate a PlusCal recipe spec (graph.tla) file."""
31
+ file = Path(path)
32
+ if not file.is_file():
33
+ return f"Error: file not found: {path}"
34
+ source = file.read_text(encoding="utf-8")
35
+
36
+ note = ""
37
+ if shutil.which("java"):
38
+ ensure_jar()
39
+ result = validate(source)
40
+ else:
41
+ note = _NO_JAVA_NOTE
42
+ result = validate_fast(source)
43
+
44
+ if isinstance(result, list):
45
+ return note + format_errors(result)
46
+ return note + format_success(result)
47
+
48
+
49
+ @server.tool()
50
+ def validate_recipe_package(recipe_dir: str) -> str:
51
+ """Validate a full recipe package (graph.tla + Python source)."""
52
+ if not Path(recipe_dir).is_dir():
53
+ return f"Error: not a directory: {recipe_dir}"
54
+
55
+ note = ""
56
+ if shutil.which("java"):
57
+ ensure_jar()
58
+ else:
59
+ note = _NO_JAVA_NOTE
60
+ result = validate_package(recipe_dir)
61
+ if isinstance(result, list):
62
+ return note + format_errors(result)
63
+ return note + format_success(result)
64
+
65
+
66
+ @server.tool()
67
+ def generate_state(recipe_dir: str) -> str:
68
+ """Regenerate the recipe's ``_state.py`` GraphState TypedDict."""
69
+ root = Path(recipe_dir)
70
+ if not root.is_dir():
71
+ return f"Error: not a directory: {recipe_dir}"
72
+
73
+ if shutil.which("java"):
74
+ ensure_jar()
75
+ result = validate_package(recipe_dir)
76
+ if isinstance(result, list):
77
+ return format_errors(result)
78
+
79
+ src_dir = root / "src" / result.module_name
80
+ if not src_dir.is_dir():
81
+ return f"Error: expected src dir at {src_dir}"
82
+
83
+ state_path = src_dir / "_state.py"
84
+ state_path.write_text(generate_state_module(result), encoding="utf-8")
85
+ return f"Wrote {state_path}"
@@ -0,0 +1,146 @@
1
+ """Data models for validated PlusCal specs.
2
+
3
+ Canonical definitions shared by the spec validator, compiler,
4
+ and sandbox modules. Other modules should import from here
5
+ rather than defining their own copies.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ # Canonical mapping from spec type names to Python types.
12
+ # The single source of truth consumed by both the compiler
13
+ # (runtime TypedDict) and the codegen (generated ``_state.py``).
14
+ SPEC_TYPES: dict[str, type] = {
15
+ "bool": bool,
16
+ "string": str,
17
+ "int": int,
18
+ "list": list,
19
+ }
20
+
21
+ # Built-in node function name for the langgraph interrupt
22
+ # primitive. Recognised by the validator (terminal label),
23
+ # compiler (graph-native node), and user-input projector
24
+ # (``interrupting`` flag).
25
+ INTERRUPT_BUILTIN = "interrupt"
26
+
27
+ # Built-in node function name for an agent-to-agent call. Like
28
+ # ``interrupt`` it runs in the langgraph runtime (harness side, not
29
+ # the sandbox) — it needs DB + dispatch access to run another agent.
30
+ # Args: ``agent`` (target slug), ``input`` (B-field → A-state-field
31
+ # mapping), ``output`` (A-state-field → B-output-field mapping).
32
+ CALL_AGENT_BUILTIN = "call_agent"
33
+
34
+ # Mid-run steering policy for a node, set via the optional
35
+ # ``interruptible`` key on a ``@node`` annotation. Controls when a
36
+ # user message that arrives while this node is the live node is folded
37
+ # into the graph:
38
+ # * ``enqueue`` — let the node finish, fold in at the next boundary.
39
+ # * ``interrupt`` — cut the node mid-flight and re-route now.
40
+ # * ``none`` — never cut here (atomic/critical section, e.g. a git
41
+ # commit/push); the steer is deferred until the graph leaves it.
42
+ # The default is deliberately ``enqueue``: never silently un-steerable,
43
+ # never cuts a critical section.
44
+ INTERRUPTIBLE_KEY = "interruptible"
45
+ INTERRUPTIBLE_CLASSES = ("enqueue", "interrupt", "none")
46
+ DEFAULT_INTERRUPTIBLE = "enqueue"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class StateField:
51
+ """A variable declared in the PlusCal variables block.
52
+
53
+ ``reducer`` names a blessed reducer from
54
+ :mod:`levain_recipe.reducers`. List-typed fields
55
+ must declare one; scalar fields may not. The default "last
56
+ write wins" behaviour is rarely correct for a list, so we
57
+ require an explicit choice.
58
+ """
59
+
60
+ name: str
61
+ type: str # key into SPEC_TYPES
62
+ default: Any = ""
63
+ reducer: str | None = None
64
+ # Free-form description collected from non-annotation comment
65
+ # lines above the variable declaration in graph.tla. Surfaced
66
+ # in the generated ``_state.py`` (as ``Annotated[T, "..."]``)
67
+ # and on the API's state-schema endpoint so the FE can render
68
+ # a per-field helper in the State tab.
69
+ description: str | None = None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class NodeAnnotation:
74
+ """Metadata for a labeled node.
75
+
76
+ Every node is a function call. Built-in functions
77
+ (``claude_agent``, ``shell``) are provided by the
78
+ platform; user functions use ``module:function`` syntax
79
+ and are resolved by the sandbox via importlib.
80
+
81
+ The ``@prompt``, ``@allowed_tools``, and
82
+ ``@permission_mode`` comment annotations are sugar —
83
+ the profile checker merges them into ``args``.
84
+ """
85
+
86
+ label: str
87
+ function: str # "claude_agent", "shell", "my_mod:score"
88
+ args: dict[str, Any] = field(default_factory=dict)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Condition:
93
+ """A single boolean predicate on a state field."""
94
+
95
+ field: str
96
+ op: str # "eq", "neq", "gt", "gte", "lt", "lte"
97
+ value: Any # True, False, 0, "pass", etc.
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Route:
102
+ """One arm of a routing decision.
103
+
104
+ All conditions are ANDed. If ``send_field`` is set,
105
+ the harness fans out: one Send per item in that list
106
+ field, each dispatched to ``target``.
107
+ """
108
+
109
+ target: str
110
+ conditions: list[Condition] = field(default_factory=list)
111
+ send_field: str | None = None
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class RoutingTable:
116
+ """Ordered routing rules for a single source node.
117
+
118
+ Routes are evaluated in order; first match wins.
119
+ The last route typically has no conditions (default).
120
+ """
121
+
122
+ source: str
123
+ routes: list[Route]
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class ValidatedSpec:
128
+ """Result of successful validation."""
129
+
130
+ source: str
131
+ module_name: str
132
+ variables: list[StateField]
133
+ nodes: list[NodeAnnotation]
134
+ routing: list[RoutingTable]
135
+ invariants: list[str] = field(default_factory=list)
136
+ constants: dict[str, Any] = field(default_factory=dict)
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ValidationError:
141
+ """A validation failure with context."""
142
+
143
+ stage: str # "parse", "profile", "graph", "translate", "tlc"
144
+ message: str
145
+ line: int | None = None
146
+ counterexample: list[dict[str, Any]] | None = None