|
1
|
"""Boilerworks MCP server — exposes the CLI as tools for AI agents.""" |
|
2
|
|
|
3
|
from __future__ import annotations |
|
4
|
|
|
5
|
import json |
|
6
|
from pathlib import Path |
|
7
|
from typing import Annotated |
|
8
|
|
|
9
|
from mcp.server.fastmcp import FastMCP |
|
10
|
|
|
11
|
mcp = FastMCP( |
|
12
|
"boilerworks", |
|
13
|
instructions=( |
|
14
|
"Boilerworks provides 26 production-ready project templates structured for AI-assisted development. " |
|
15
|
"Use list_templates to explore options, create_manifest to build a boilerworks.yaml, " |
|
16
|
"and init_project to scaffold the project on disk." |
|
17
|
), |
|
18
|
) |
|
19
|
|
|
20
|
|
|
21
|
# ── Templates ───────────────────────────────────────────────────────────────── |
|
22
|
|
|
23
|
|
|
24
|
@mcp.tool() |
|
25
|
def list_templates( |
|
26
|
size: Annotated[str | None, "Filter by size: full | micro | edge"] = None, |
|
27
|
language: Annotated[str | None, "Filter by language: python | typescript | ruby | php | java | go | elixir | rust | svelte"] = None, |
|
28
|
status: Annotated[str | None, "Filter by status: done | building | planned"] = None, |
|
29
|
) -> str: |
|
30
|
"""List all available Boilerworks templates, optionally filtered.""" |
|
31
|
from boilerworks.registry import Registry |
|
32
|
|
|
33
|
registry = Registry() |
|
34
|
templates = registry.list_all() |
|
35
|
|
|
36
|
if size: |
|
37
|
templates = [t for t in templates if t.size == size] |
|
38
|
if language: |
|
39
|
templates = [t for t in templates if t.language == language] |
|
40
|
if status: |
|
41
|
templates = [t for t in templates if t.status == status] |
|
42
|
|
|
43
|
rows = [ |
|
44
|
{ |
|
45
|
"name": t.name, |
|
46
|
"size": t.size, |
|
47
|
"language": t.language, |
|
48
|
"backend": t.backend, |
|
49
|
"frontend": t.frontend, |
|
50
|
"status": t.status, |
|
51
|
"best_for": t.best_for, |
|
52
|
} |
|
53
|
for t in templates |
|
54
|
] |
|
55
|
return json.dumps(rows, indent=2) |
|
56
|
|
|
57
|
|
|
58
|
@mcp.tool() |
|
59
|
def get_template( |
|
60
|
name: Annotated[str, "Template name, e.g. django-nextjs"], |
|
61
|
) -> str: |
|
62
|
"""Get full details for a specific Boilerworks template.""" |
|
63
|
from boilerworks.registry import Registry |
|
64
|
|
|
65
|
registry = Registry() |
|
66
|
template = registry.get_by_name(name) |
|
67
|
|
|
68
|
if template is None: |
|
69
|
valid = ", ".join(sorted(registry.names())) |
|
70
|
return f"Template '{name}' not found. Available: {valid}" |
|
71
|
|
|
72
|
return json.dumps(template.model_dump(), indent=2) |
|
73
|
|
|
74
|
|
|
75
|
@mcp.tool() |
|
76
|
def search_templates( |
|
77
|
query: Annotated[str, "Search query matched against name, description, and best_for"], |
|
78
|
) -> str: |
|
79
|
"""Search templates by keyword.""" |
|
80
|
from boilerworks.registry import Registry |
|
81
|
|
|
82
|
registry = Registry() |
|
83
|
results = registry.search(query) |
|
84
|
|
|
85
|
if not results: |
|
86
|
return f"No templates matched '{query}'." |
|
87
|
|
|
88
|
rows = [{"name": t.name, "size": t.size, "backend": t.backend, "frontend": t.frontend, "best_for": t.best_for} for t in results] |
|
89
|
return json.dumps(rows, indent=2) |
|
90
|
|
|
91
|
|
|
92
|
# ── Manifest ────────────────────────────────────────────────────────────────── |
|
93
|
|
|
94
|
|
|
95
|
@mcp.tool() |
|
96
|
def create_manifest( |
|
97
|
project: Annotated[str, "Project slug (lowercase, letters/digits/hyphens)"], |
|
98
|
family: Annotated[str, "Template family name, e.g. django-nextjs"], |
|
99
|
size: Annotated[str, "Template size: full | micro | edge"], |
|
100
|
cloud: Annotated[str | None, "Cloud provider: aws | gcp | azure"] = None, |
|
101
|
region: Annotated[str | None, "Cloud region, e.g. us-east-1"] = None, |
|
102
|
topology: Annotated[str, "standard | api-only | omni"] = "standard", |
|
103
|
domain: Annotated[str | None, "Production domain, e.g. myapp.com"] = None, |
|
104
|
ops: Annotated[bool, "Include Terraform infrastructure repo"] = False, |
|
105
|
mobile: Annotated[bool, "Include mobile app template (Full only)"] = False, |
|
106
|
web_presence: Annotated[bool, "Include marketing site template (Full only)"] = False, |
|
107
|
compliance: Annotated[list[str] | None, "Compliance requirements: soc2 | hipaa | pci-dss | gdpr"] = None, |
|
108
|
email: Annotated[str | None, "Email service: ses | sendgrid | mailgun"] = None, |
|
109
|
storage: Annotated[str | None, "File storage: s3 | gcs | azure-blob"] = None, |
|
110
|
search: Annotated[str | None, "Search engine: opensearch | meilisearch"] = None, |
|
111
|
cache: Annotated[str, "Cache: redis | memcached"] = "redis", |
|
112
|
database: Annotated[str, "Database: postgres | mysql | sqlite"] = "postgres", |
|
113
|
e2e: Annotated[str | None, "E2E framework: playwright | cypress"] = None, |
|
114
|
) -> str: |
|
115
|
"""Build and validate a boilerworks.yaml manifest. Returns the YAML content ready to write to disk.""" |
|
116
|
from boilerworks.manifest import BoilerworksManifest, DataConfig, ServicesConfig, TestingConfig |
|
117
|
|
|
118
|
try: |
|
119
|
manifest = BoilerworksManifest( |
|
120
|
project=project, |
|
121
|
family=family, |
|
122
|
size=size, |
|
123
|
topology=topology, |
|
124
|
cloud=cloud, |
|
125
|
ops=ops, |
|
126
|
region=region, |
|
127
|
domain=domain, |
|
128
|
mobile=mobile, |
|
129
|
web_presence=web_presence, |
|
130
|
compliance=compliance or [], |
|
131
|
services=ServicesConfig( |
|
132
|
email=email, |
|
133
|
storage=storage, |
|
134
|
search=search, |
|
135
|
cache=cache, |
|
136
|
), |
|
137
|
data=DataConfig(database=database), |
|
138
|
testing=TestingConfig(e2e=e2e), |
|
139
|
) |
|
140
|
except Exception as exc: |
|
141
|
return f"Invalid manifest: {exc}" |
|
142
|
|
|
143
|
return manifest.to_yaml() |
|
144
|
|
|
145
|
|
|
146
|
@mcp.tool() |
|
147
|
def validate_manifest( |
|
148
|
yaml_content: Annotated[str, "Contents of a boilerworks.yaml file to validate"], |
|
149
|
) -> str: |
|
150
|
"""Validate a boilerworks.yaml manifest. Returns 'valid' or a description of the errors.""" |
|
151
|
from boilerworks.manifest import BoilerworksManifest |
|
152
|
|
|
153
|
try: |
|
154
|
BoilerworksManifest.from_yaml(yaml_content) |
|
155
|
return "valid" |
|
156
|
except Exception as exc: |
|
157
|
return f"invalid: {exc}" |
|
158
|
|
|
159
|
|
|
160
|
# ── Project generation ───────────────────────────────────────────────────────── |
|
161
|
|
|
162
|
|
|
163
|
def _run_cli(args: list[str], manifest_yaml: str) -> str: |
|
164
|
"""Write manifest to a temp file, run the boilerworks CLI, return combined output.""" |
|
165
|
import subprocess |
|
166
|
import sys |
|
167
|
import tempfile |
|
168
|
|
|
169
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: |
|
170
|
f.write(manifest_yaml) |
|
171
|
tmp_path = f.name |
|
172
|
|
|
173
|
try: |
|
174
|
result = subprocess.run( |
|
175
|
[sys.executable, "-m", "boilerworks.cli"] + args + ["--manifest", tmp_path], |
|
176
|
capture_output=True, |
|
177
|
text=True, |
|
178
|
) |
|
179
|
output = (result.stdout + result.stderr).strip() |
|
180
|
return output or ("OK" if result.returncode == 0 else "Command failed with no output.") |
|
181
|
finally: |
|
182
|
Path(tmp_path).unlink(missing_ok=True) |
|
183
|
|
|
184
|
|
|
185
|
@mcp.tool() |
|
186
|
def dry_run( |
|
187
|
manifest_yaml: Annotated[str, "Contents of a boilerworks.yaml manifest"], |
|
188
|
output_dir: Annotated[str, "Directory where the project would be created"] = ".", |
|
189
|
) -> str: |
|
190
|
"""Preview what boilerworks init would do without writing any files.""" |
|
191
|
return _run_cli(["init", "--dry-run", "--output", output_dir], manifest_yaml) |
|
192
|
|
|
193
|
|
|
194
|
@mcp.tool() |
|
195
|
def init_project( |
|
196
|
manifest_yaml: Annotated[str, "Contents of a boilerworks.yaml manifest"], |
|
197
|
output_dir: Annotated[str, "Directory to generate the project in"] = ".", |
|
198
|
) -> str: |
|
199
|
""" |
|
200
|
Scaffold a new project from a boilerworks.yaml manifest. |
|
201
|
Clones the template, applies substitutions, and runs git init. |
|
202
|
Requires network access (GitHub). This may take 10-30 seconds. |
|
203
|
""" |
|
204
|
return _run_cli(["init", "--output", output_dir], manifest_yaml) |
|
205
|
|
|
206
|
|
|
207
|
def main() -> None: |
|
208
|
mcp.run() |
|
209
|
|
|
210
|
|
|
211
|
if __name__ == "__main__": |
|
212
|
main() |
|
213
|
|