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.
- makefile_mcp/__init__.py +5 -0
- makefile_mcp/app.py +125 -0
- makefile_mcp/catalog.py +146 -0
- makefile_mcp/cli.py +219 -0
- makefile_mcp/config.py +74 -0
- makefile_mcp/doctor.py +213 -0
- makefile_mcp/errors.py +41 -0
- makefile_mcp/execution.py +177 -0
- makefile_mcp/filesystem.py +131 -0
- makefile_mcp/inputs.py +182 -0
- makefile_mcp/makefile.py +355 -0
- makefile_mcp/mcp/__init__.py +5 -0
- makefile_mcp/mcp/presentation.py +285 -0
- makefile_mcp/mcp/server.py +198 -0
- makefile_mcp/models.py +329 -0
- makefile_mcp/process.py +188 -0
- makefile_mcp/syntax.py +82 -0
- makefile_mcp/version.py +1 -0
- makefile_mcp-0.1.0.dist-info/METADATA +212 -0
- makefile_mcp-0.1.0.dist-info/RECORD +23 -0
- makefile_mcp-0.1.0.dist-info/WHEEL +4 -0
- makefile_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- makefile_mcp-0.1.0.dist-info/licenses/LICENSE +202 -0
makefile_mcp/__init__.py
ADDED
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
|
+
)
|
makefile_mcp/catalog.py
ADDED
|
@@ -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
|