makefile-mcp 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,5 @@
1
+ """Makefile MCP package."""
2
+
3
+ from makefile_mcp.version import __version__
4
+
5
+ __all__ = ["__version__"]
makefile_mcp/app.py ADDED
@@ -0,0 +1,125 @@
1
+ """Small composition root and protocol-agnostic application facade."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from makefile_mcp.catalog import Catalog, Contexts
7
+ from makefile_mcp.config import load_config_state
8
+ from makefile_mcp.doctor import run_doctor
9
+ from makefile_mcp.errors import ConfigurationError
10
+ from makefile_mcp.execution import TaskExecutor
11
+ from makefile_mcp.filesystem import (
12
+ FileContextLock,
13
+ Fingerprint,
14
+ detect_repository_root,
15
+ fingerprint,
16
+ )
17
+ from makefile_mcp.makefile import StaticMakeInspector
18
+ from makefile_mcp.models import DoctorResult, MakefileMcpConfig, TaskDefinition, TaskResult
19
+ from makefile_mcp.process import SubprocessRunner
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Application:
24
+ """Protocol-independent facade over catalog, diagnostics, and task execution."""
25
+
26
+ root: Path
27
+ config: MakefileMcpConfig
28
+ contexts: Contexts
29
+ catalog: Catalog
30
+ executor: TaskExecutor
31
+ governed: bool
32
+ policy_fingerprint: Fingerprint
33
+
34
+ def _ensure_policy_current(self) -> None:
35
+ """Fail closed if the authorization policy changed since application startup."""
36
+ try:
37
+ current = fingerprint([self.root / ".makefile-mcp.yaml"])
38
+ except (OSError, RuntimeError) as exc:
39
+ raise ConfigurationError(f"could not verify .makefile-mcp.yaml state: {exc}") from exc
40
+ if current != self.policy_fingerprint:
41
+ raise ConfigurationError(
42
+ ".makefile-mcp.yaml changed since startup; "
43
+ "restart makefile-mcp to apply policy safely"
44
+ )
45
+
46
+ def list_contexts(self) -> list[str]:
47
+ """Return all configured execution-context names, including ``root``."""
48
+ self._ensure_policy_current()
49
+ return self.contexts.names()
50
+
51
+ def list_tasks(self, context: str = "root") -> list[TaskDefinition]:
52
+ """Return callable tasks for one context in stable name order."""
53
+ self._ensure_policy_current()
54
+ return self.catalog.list(context)
55
+
56
+ def describe_task(self, task: str, context: str = "root") -> TaskDefinition:
57
+ """Return the callable contract for one task/context pair."""
58
+ self._ensure_policy_current()
59
+ return self.catalog.describe(task, context)
60
+
61
+ def resolve_capability(self, capability: str, context: str = "root") -> TaskDefinition:
62
+ """Resolve a semantic capability to its callable task in one context."""
63
+ self._ensure_policy_current()
64
+ return self.catalog.resolve_capability(capability, context)
65
+
66
+ def list_capabilities(self, context: str = "root") -> dict[str, str]:
67
+ """Return capability mappings whose target is callable in one context."""
68
+ self._ensure_policy_current()
69
+ exposed = {task.name for task in self.catalog.list(context)}
70
+ return {
71
+ name: target for name, target in self.config.capabilities.items() if target in exposed
72
+ }
73
+
74
+ async def run_task(
75
+ self,
76
+ task: str,
77
+ variables: dict[str, str] | None = None,
78
+ context: str = "root",
79
+ *,
80
+ preview: bool = False,
81
+ ) -> TaskResult:
82
+ """Execute or preview one callable task through the shared bounded executor."""
83
+ self._ensure_policy_current()
84
+ return await self.executor.run(task, variables, context, preview=preview)
85
+
86
+ def doctor(self) -> DoctorResult:
87
+ """Run read-only diagnostics against repository, policy, and discovery state."""
88
+ self._ensure_policy_current()
89
+ return run_doctor(
90
+ root=self.root,
91
+ config=self.config,
92
+ contexts=self.contexts,
93
+ catalog=self.catalog,
94
+ governed=self.governed,
95
+ )
96
+
97
+
98
+ def build_application(start: Path | None = None) -> Application:
99
+ """Compose one application instance rooted at *start* or the current directory."""
100
+ root = detect_repository_root(start or Path.cwd())
101
+ loaded = load_config_state(root)
102
+ contexts = Contexts(root, loaded.config)
103
+ catalog = Catalog(
104
+ config=loaded.config,
105
+ contexts=contexts,
106
+ inspector=StaticMakeInspector(root),
107
+ governed=loaded.governed,
108
+ )
109
+ executor = TaskExecutor(
110
+ root=root,
111
+ config=loaded.config,
112
+ contexts=contexts,
113
+ catalog=catalog,
114
+ runner=SubprocessRunner(),
115
+ lock=FileContextLock(root),
116
+ )
117
+ return Application(
118
+ root=root,
119
+ config=loaded.config,
120
+ contexts=contexts,
121
+ catalog=catalog,
122
+ executor=executor,
123
+ governed=loaded.governed,
124
+ policy_fingerprint=loaded.policy_fingerprint,
125
+ )
@@ -0,0 +1,146 @@
1
+ """Resolve contexts and build the authorized public task catalog."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from makefile_mcp.errors import ContextNotFound, MakeInspectionError, TaskNotExposed, TaskNotFound
7
+ from makefile_mcp.filesystem import Fingerprint, ensure_within_root, fingerprint
8
+ from makefile_mcp.makefile import StaticMakeInspector
9
+ from makefile_mcp.models import MakefileMcpConfig, ProjectContext, TaskDefinition, TaskRisk
10
+
11
+
12
+ def _fingerprint(paths: list[Path]) -> Fingerprint:
13
+ """Normalize filesystem fingerprint failures into the discovery error boundary."""
14
+ try:
15
+ return fingerprint(paths)
16
+ except (OSError, RuntimeError) as exc:
17
+ raise MakeInspectionError(f"could not fingerprint Make discovery inputs: {exc}") from exc
18
+
19
+
20
+ class Contexts:
21
+ """Resolve configured context names to confined repository directories."""
22
+
23
+ def __init__(self, root: Path, config: MakefileMcpConfig):
24
+ """Create a context resolver for one repository/configuration pair."""
25
+ self.root = root
26
+ self.config = config
27
+
28
+ def resolve(self, name: str = "root") -> ProjectContext:
29
+ """Resolve one context name to a repository-confined directory."""
30
+ if name == "root":
31
+ return ProjectContext(directory=self.root)
32
+ spec = self.config.contexts.get(name)
33
+ if spec is None:
34
+ raise ContextNotFound(f"unknown context: {name}")
35
+ directory = ensure_within_root(self.root, self.root / spec.directory, must_exist=True)
36
+ if not directory.is_dir():
37
+ raise ContextNotFound(f"context is not a directory: {name}")
38
+ return ProjectContext(directory=directory)
39
+
40
+ def names(self) -> list[str]:
41
+ """Return ``root`` plus configured context names in stable order."""
42
+ return ["root", *sorted(self.config.contexts)]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CatalogSnapshot:
47
+ """Cached discovery/exposure view for one context."""
48
+
49
+ tasks: dict[str, TaskDefinition]
50
+ discovered_targets: set[str]
51
+ warnings: list[str]
52
+ tracked_files: list[Path]
53
+
54
+
55
+ class Catalog:
56
+ """Cache Make discovery and apply auto/governed exposure policy."""
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ config: MakefileMcpConfig,
62
+ contexts: Contexts,
63
+ inspector: StaticMakeInspector,
64
+ governed: bool,
65
+ ):
66
+ """Create a catalog over one immutable configuration/exposure mode."""
67
+ self.config = config
68
+ self.contexts = contexts
69
+ self.inspector = inspector
70
+ self.governed = governed
71
+ self._cache: dict[str, tuple[Fingerprint, CatalogSnapshot]] = {}
72
+
73
+ def snapshot(self, context_name: str = "root") -> CatalogSnapshot:
74
+ """Return the current cached-or-refreshed catalog snapshot for one context."""
75
+ context = self.contexts.resolve(context_name)
76
+ base_paths = [context.directory / "Makefile"]
77
+ cached = self._cache.get(context_name)
78
+ if cached and cached[0] == _fingerprint(base_paths + cached[1].tracked_files):
79
+ return cached[1]
80
+
81
+ raw = self.inspector.discover(directory=context.directory)
82
+ tasks: dict[str, TaskDefinition] = {}
83
+ for name, target in raw.targets.items():
84
+ if not self.governed:
85
+ # Auto mode mirrors lightweight Makefile MCP servers: every target our conservative
86
+ # inspector can identify is callable. Inputs stay parameterless because there is
87
+ # no operator-declared variable contract.
88
+ tasks[name] = TaskDefinition(
89
+ name=name,
90
+ description=target.description,
91
+ context=context_name,
92
+ risk=TaskRisk.UNKNOWN,
93
+ timeout_seconds=self.config.defaults.timeout_seconds,
94
+ )
95
+ continue
96
+
97
+ task_config = self.config.tasks.get(name)
98
+ # Governed mode is deny-by-default. Discovery is broader than authorization and a
99
+ # target is public only when config enables it for this exact context.
100
+ if (
101
+ task_config is None
102
+ or not task_config.enabled
103
+ or context_name not in task_config.contexts
104
+ ):
105
+ continue
106
+
107
+ timeout = task_config.timeout_seconds or self.config.defaults.timeout_seconds
108
+ tasks[name] = TaskDefinition(
109
+ name=name,
110
+ description=task_config.description or target.description,
111
+ context=context_name,
112
+ risk=task_config.risk or TaskRisk.UNKNOWN,
113
+ timeout_seconds=timeout,
114
+ variables=task_config.variables,
115
+ )
116
+
117
+ snapshot = CatalogSnapshot(
118
+ tasks=tasks,
119
+ discovered_targets=set(raw.targets),
120
+ warnings=raw.warnings,
121
+ tracked_files=[Path(path) for path in raw.tracked_files],
122
+ )
123
+ self._cache[context_name] = (_fingerprint(base_paths + snapshot.tracked_files), snapshot)
124
+ return snapshot
125
+
126
+ def list(self, context: str = "root") -> list[TaskDefinition]:
127
+ """Return callable tasks in one context, sorted by target name."""
128
+ tasks = self.snapshot(context).tasks
129
+ return [tasks[name] for name in sorted(tasks)]
130
+
131
+ def describe(self, task: str, context: str = "root") -> TaskDefinition:
132
+ """Return one callable task or distinguish hidden from unknown targets."""
133
+ snapshot = self.snapshot(context)
134
+ definition = snapshot.tasks.get(task)
135
+ if definition:
136
+ return definition
137
+ if task in snapshot.discovered_targets:
138
+ raise TaskNotExposed(f"target exists but is not exposed in context {context!r}: {task}")
139
+ raise TaskNotFound(f"unknown Make target: {task}")
140
+
141
+ def resolve_capability(self, capability: str, context: str = "root") -> TaskDefinition:
142
+ """Resolve a semantic capability name to a callable target in one context."""
143
+ target = self.config.capabilities.get(capability)
144
+ if not target:
145
+ raise TaskNotFound(f"unknown capability: {capability}")
146
+ return self.describe(target, context)
makefile_mcp/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ """Typer CLI. All task policy and execution remain in the application modules."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Annotated, Any, Never
7
+
8
+ import typer
9
+ from pydantic import BaseModel
10
+
11
+ from makefile_mcp.app import Application, build_application
12
+ from makefile_mcp.errors import MakefileMcpError
13
+ from makefile_mcp.mcp import McpPresentation
14
+ from makefile_mcp.version import __version__
15
+
16
+ cli = typer.Typer(
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ help="Expose Make targets over CLI and MCP with optional governance.",
20
+ )
21
+
22
+
23
+ class State:
24
+ """Mutable CLI process state shared by Typer command callbacks."""
25
+
26
+ root: Path | None = None
27
+ application: Application | None = None
28
+
29
+
30
+ state = State()
31
+
32
+
33
+ def _app() -> Application:
34
+ if state.application is None:
35
+ state.application = build_application(state.root)
36
+ return state.application
37
+
38
+
39
+ def _data(value: Any) -> Any:
40
+ if isinstance(value, BaseModel):
41
+ return value.model_dump(mode="json")
42
+ if isinstance(value, list):
43
+ return [_data(item) for item in value]
44
+ return value
45
+
46
+
47
+ def _json(value: Any) -> None:
48
+ typer.echo(json.dumps(_data(value), indent=2, sort_keys=True))
49
+
50
+
51
+ def _fail(exc: MakefileMcpError) -> Never:
52
+ typer.echo(f"error: {exc}", err=True)
53
+ raise typer.Exit(code=2)
54
+
55
+
56
+ def _version_callback(value: bool) -> None:
57
+ if value:
58
+ typer.echo(__version__)
59
+ raise typer.Exit()
60
+
61
+
62
+ @cli.callback()
63
+ def callback(
64
+ root: Annotated[
65
+ Path | None,
66
+ typer.Option("--root", help="Repository path; defaults to auto-detection."),
67
+ ] = None,
68
+ version: Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--version",
72
+ callback=_version_callback,
73
+ is_eager=True,
74
+ help="Show the Makefile MCP version and exit.",
75
+ ),
76
+ ] = False,
77
+ ) -> None:
78
+ """Set the repository root used by subsequent CLI commands."""
79
+ state.root = root
80
+ state.application = None
81
+
82
+
83
+ @cli.command("list")
84
+ def list_command(
85
+ context: Annotated[str, typer.Option("--context", "-c")] = "root",
86
+ json_output: Annotated[bool, typer.Option("--json")] = False,
87
+ ) -> None:
88
+ """List callable tasks in one context."""
89
+ try:
90
+ tasks = _app().list_tasks(context)
91
+ except MakefileMcpError as exc:
92
+ _fail(exc)
93
+ if json_output:
94
+ _json(tasks)
95
+ return
96
+ for task in tasks:
97
+ description = f" — {task.description}" if task.description else ""
98
+ typer.echo(f"{task.name:<24} [{task.risk}] {description}".rstrip())
99
+
100
+
101
+ @cli.command("describe")
102
+ def describe_command(
103
+ task: str,
104
+ context: Annotated[str, typer.Option("--context", "-c")] = "root",
105
+ json_output: Annotated[bool, typer.Option("--json")] = False,
106
+ ) -> None:
107
+ """Describe one callable task and its governed input contract."""
108
+ try:
109
+ definition = _app().describe_task(task, context)
110
+ except MakefileMcpError as exc:
111
+ _fail(exc)
112
+ if json_output:
113
+ _json(definition)
114
+ return
115
+ typer.echo(f"Task: {definition.name}")
116
+ typer.echo(f"Context: {definition.context}")
117
+ typer.echo(f"Risk: {definition.risk}")
118
+ typer.echo(f"Timeout: {definition.timeout_seconds}s")
119
+ if definition.description:
120
+ typer.echo(f"Description: {definition.description}")
121
+ if definition.variables:
122
+ typer.echo("Variables:")
123
+ for name, spec in definition.variables.items():
124
+ marker = "required" if spec.required else "optional"
125
+ typer.echo(f" {name}: {spec.type} ({marker})")
126
+
127
+
128
+ @cli.command("run")
129
+ def run_command(
130
+ task: str,
131
+ assignments: Annotated[
132
+ list[str] | None,
133
+ typer.Argument(help="Declared KEY=VALUE task variables."),
134
+ ] = None,
135
+ context: Annotated[str, typer.Option("--context", "-c")] = "root",
136
+ preview: Annotated[
137
+ bool,
138
+ typer.Option(
139
+ "--preview",
140
+ help="Ask GNU Make for a --dry-run preview; this is not a side-effect-free sandbox.",
141
+ ),
142
+ ] = False,
143
+ json_output: Annotated[bool, typer.Option("--json")] = False,
144
+ ) -> None:
145
+ """Execute or preview one callable task with optional declared ``KEY=VALUE`` inputs."""
146
+ variables: dict[str, str] = {}
147
+ for item in assignments or []:
148
+ if "=" not in item:
149
+ typer.echo(f"error: variable must use KEY=VALUE syntax: {item}", err=True)
150
+ raise typer.Exit(code=2)
151
+ key, value = item.split("=", 1)
152
+ if key in variables:
153
+ typer.echo(f"error: duplicate variable: {key}", err=True)
154
+ raise typer.Exit(code=2)
155
+ variables[key] = value
156
+ try:
157
+ result = asyncio.run(_app().run_task(task, variables, context, preview=preview))
158
+ except MakefileMcpError as exc:
159
+ _fail(exc)
160
+ if json_output:
161
+ _json(result)
162
+ else:
163
+ mode = "preview" if result.preview else "run"
164
+ typer.echo(f"{result.task}: {result.status} [{mode}] ({result.duration_ms} ms)")
165
+ if result.stdout:
166
+ typer.echo(result.stdout, nl=not result.stdout.endswith("\n"))
167
+ if result.stderr:
168
+ typer.echo(result.stderr, err=True, nl=not result.stderr.endswith("\n"))
169
+ if result.truncated:
170
+ typer.echo("[output truncated]", err=True)
171
+ if result.status != "passed":
172
+ raise typer.Exit(code=1)
173
+
174
+
175
+ @cli.command("doctor")
176
+ def doctor_command(json_output: Annotated[bool, typer.Option("--json")] = False) -> None:
177
+ """Run read-only repository diagnostics."""
178
+ try:
179
+ result = _app().doctor()
180
+ except MakefileMcpError as exc:
181
+ _fail(exc)
182
+ if json_output:
183
+ _json(result)
184
+ else:
185
+ typer.echo("doctor: ok" if result.ok else "doctor: problems found")
186
+ for finding in result.findings:
187
+ scope = "/".join(part for part in [finding.context, finding.task] if part)
188
+ suffix = f" ({scope})" if scope else ""
189
+ typer.echo(f"[{finding.severity}] {finding.code}{suffix}: {finding.message}")
190
+ if not result.ok:
191
+ raise typer.Exit(code=1)
192
+
193
+
194
+ @cli.command("serve")
195
+ def serve_command(
196
+ tools: Annotated[
197
+ McpPresentation,
198
+ typer.Option(
199
+ "--tools",
200
+ help="MCP presentation: direct per-target tools, generic list/describe/run, or both.",
201
+ ),
202
+ ] = McpPresentation.DIRECT,
203
+ ) -> None:
204
+ """Run the MCP stdio server using the selected presentation mode."""
205
+ from makefile_mcp.mcp.server import run_stdio_server
206
+
207
+ try:
208
+ run_stdio_server(_app(), presentation=tools)
209
+ except MakefileMcpError as exc:
210
+ _fail(exc)
211
+
212
+
213
+ def main() -> None:
214
+ """Run the Makefile MCP command-line interface."""
215
+ cli()
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
makefile_mcp/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """Load optional metadata-only Makefile MCP configuration."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from pydantic import ValidationError
8
+
9
+ from makefile_mcp.errors import ConfigurationError
10
+ from makefile_mcp.filesystem import Fingerprint, fingerprint
11
+ from makefile_mcp.models import MakefileMcpConfig
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class LoadedConfig:
16
+ """Validated repository policy together with its exposure-mode decision."""
17
+
18
+ config: MakefileMcpConfig
19
+ governed: bool
20
+ policy_fingerprint: Fingerprint
21
+
22
+
23
+ def load_config_state(repository_root: Path) -> LoadedConfig:
24
+ """Load repository policy and decide auto/governed mode from one filesystem observation.
25
+
26
+ Args:
27
+ repository_root: Detected trusted repository root.
28
+
29
+ Returns:
30
+ Validated configuration, exposure mode, and the policy-file fingerprint used by the
31
+ application to fail closed if authorization policy changes while it is running.
32
+
33
+ Raises:
34
+ ConfigurationError: If the policy file exists but cannot be parsed or validated, or if
35
+ it changes while being loaded.
36
+ """
37
+ path = repository_root / ".makefile-mcp.yaml"
38
+ try:
39
+ before = fingerprint([path])
40
+ if not path.exists():
41
+ after = fingerprint([path])
42
+ if before != after:
43
+ raise ConfigurationError(
44
+ ".makefile-mcp.yaml changed while configuration was loaded"
45
+ )
46
+ return LoadedConfig(
47
+ config=MakefileMcpConfig(),
48
+ governed=False,
49
+ policy_fingerprint=after,
50
+ )
51
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
52
+ after = fingerprint([path])
53
+ if before != after:
54
+ raise ConfigurationError(".makefile-mcp.yaml changed while configuration was loaded")
55
+ if not isinstance(raw, dict):
56
+ raise ConfigurationError(".makefile-mcp.yaml must contain a mapping")
57
+ return LoadedConfig(
58
+ config=MakefileMcpConfig.model_validate(raw),
59
+ governed=True,
60
+ policy_fingerprint=after,
61
+ )
62
+ except ConfigurationError:
63
+ raise
64
+ except (OSError, RuntimeError, yaml.YAMLError, ValidationError) as exc:
65
+ raise ConfigurationError(f"invalid .makefile-mcp.yaml: {exc}") from exc
66
+
67
+
68
+ def load_config(repository_root: Path) -> MakefileMcpConfig:
69
+ """Load only the validated repository configuration.
70
+
71
+ This compatibility convenience delegates to :func:`load_config_state`; the application
72
+ composition root uses the state form so config contents and exposure mode share one source.
73
+ """
74
+ return load_config_state(repository_root).config