create-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.
- create_mcp/__init__.py +10 -0
- create_mcp/__main__.py +8 -0
- create_mcp/cli.py +193 -0
- create_mcp/generator.py +178 -0
- create_mcp/presets.py +57 -0
- create_mcp/templates/__init__.py +7 -0
- create_mcp/templates/auth/src/__pkg__/auth.py.jinja +34 -0
- create_mcp/templates/auth/tests/test_auth.py.jinja +44 -0
- create_mcp/templates/base/Dockerfile.jinja +32 -0
- create_mcp/templates/base/LICENSE.jinja +21 -0
- create_mcp/templates/base/README.md.jinja +122 -0
- create_mcp/templates/base/dot-dockerignore.jinja +13 -0
- create_mcp/templates/base/dot-env.example.jinja +21 -0
- create_mcp/templates/base/dot-github/workflows/ci.yml.jinja +32 -0
- create_mcp/templates/base/dot-gitignore.jinja +29 -0
- create_mcp/templates/base/dot-pre-commit-config.yaml.jinja +15 -0
- create_mcp/templates/base/pyproject.toml.jinja +58 -0
- create_mcp/templates/base/src/__pkg__/__init__.py.jinja +3 -0
- create_mcp/templates/base/src/__pkg__/__main__.py.jinja +17 -0
- create_mcp/templates/base/src/__pkg__/app.py.jinja +31 -0
- create_mcp/templates/base/src/__pkg__/server.py.jinja +30 -0
- create_mcp/templates/base/src/__pkg__/settings.py.jinja +30 -0
- create_mcp/templates/base/tests/__init__.py.jinja +0 -0
- create_mcp/templates/base/tests/conftest.py.jinja +18 -0
- create_mcp/templates/base/tests/test_server.py.jinja +19 -0
- create_mcp/templates/presets/agent_tools/src/__pkg__/tools.py.jinja +73 -0
- create_mcp/templates/presets/agent_tools/tests/test_tools.py.jinja +45 -0
- create_mcp/templates/presets/api_wrapper/src/__pkg__/tools.py.jinja +41 -0
- create_mcp/templates/presets/api_wrapper/tests/test_tools.py.jinja +29 -0
- create_mcp/templates/presets/db/src/__pkg__/tools.py.jinja +79 -0
- create_mcp/templates/presets/db/tests/test_tools.py.jinja +29 -0
- create_mcp/templates/presets/minimal/src/__pkg__/tools.py.jinja +40 -0
- create_mcp/templates/presets/minimal/tests/test_tools.py.jinja +25 -0
- create_mcp-0.1.0.dist-info/METADATA +157 -0
- create_mcp-0.1.0.dist-info/RECORD +38 -0
- create_mcp-0.1.0.dist-info/WHEEL +4 -0
- create_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- create_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
create_mcp/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""create-mcp — scaffold production-ready Python MCP servers in one command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
# The FastMCP release the generated projects are pinned to / tested against.
|
|
8
|
+
FASTMCP_TARGET = "3.4"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__", "FASTMCP_TARGET"]
|
create_mcp/__main__.py
ADDED
create_mcp/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""The ``create-mcp`` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.prompt import Confirm, Prompt
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from . import FASTMCP_TARGET, __version__
|
|
16
|
+
from .generator import GeneratorError, ProjectConfig, generate, to_package_name
|
|
17
|
+
from .presets import DEFAULT_PRESET, PRESETS, preset_choices
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
err_console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
TRANSPORTS = ["streamable-http", "stdio"]
|
|
23
|
+
AUTH_MODES = ["none", "oauth"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version_callback(value: bool) -> None:
|
|
27
|
+
if value:
|
|
28
|
+
console.print(f"create-mcp {__version__} [dim](targets FastMCP {FASTMCP_TARGET}.x)[/dim]")
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(cmd: list[str], cwd: Path) -> bool:
|
|
33
|
+
"""Run a command, streaming nothing; return True on success, False otherwise."""
|
|
34
|
+
try:
|
|
35
|
+
subprocess.run(cmd, cwd=cwd, check=True, capture_output=True)
|
|
36
|
+
return True
|
|
37
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _maybe_git_init(target: Path) -> None:
|
|
42
|
+
if shutil.which("git") is None:
|
|
43
|
+
console.print(" [yellow]›[/yellow] git not found — skipping repository init")
|
|
44
|
+
return
|
|
45
|
+
if _run(["git", "init", "-q"], target) and _run(["git", "add", "-A"], target):
|
|
46
|
+
_run(["git", "commit", "-q", "-m", "Initial commit (create-mcp)"], target)
|
|
47
|
+
console.print(" [green]✓[/green] initialised git repository")
|
|
48
|
+
else:
|
|
49
|
+
console.print(" [yellow]›[/yellow] could not initialise git repository")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _maybe_uv_sync(target: Path) -> None:
|
|
53
|
+
if shutil.which("uv") is None:
|
|
54
|
+
console.print(" [yellow]›[/yellow] uv not found — skipping dependency install")
|
|
55
|
+
return
|
|
56
|
+
console.print(" [dim]…[/dim] installing dependencies with uv")
|
|
57
|
+
if _run(["uv", "sync"], target):
|
|
58
|
+
console.print(" [green]✓[/green] installed dependencies (uv sync)")
|
|
59
|
+
else:
|
|
60
|
+
console.print(" [yellow]›[/yellow] uv sync failed — run it yourself later")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _maybe_precommit(target: Path) -> None:
|
|
64
|
+
if shutil.which("git") is None or not (target / ".git").exists():
|
|
65
|
+
return
|
|
66
|
+
if shutil.which("uv") is not None and _run(["uv", "run", "pre-commit", "install"], target):
|
|
67
|
+
console.print(" [green]✓[/green] installed pre-commit hooks")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _prompt_choice(label: str, choices: list[str], default: str) -> str:
|
|
71
|
+
return Prompt.ask(f"[bold]{label}[/bold]", choices=choices, default=default)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create( # noqa: C901 - the CLI orchestration is intentionally linear
|
|
75
|
+
project_name: str = typer.Argument(None, help="Name of the project / directory to create."),
|
|
76
|
+
preset: str = typer.Option(
|
|
77
|
+
None, "--preset", "-p", help=f"Preset: {', '.join(preset_choices())}."
|
|
78
|
+
),
|
|
79
|
+
transport: str = typer.Option(
|
|
80
|
+
None, "--transport", "-t", help="Transport: streamable-http or stdio."
|
|
81
|
+
),
|
|
82
|
+
auth: str = typer.Option(
|
|
83
|
+
None, "--auth", "-a", help="Auth mode: none or oauth (OAuth 2.1 resource server)."
|
|
84
|
+
),
|
|
85
|
+
package_name: str = typer.Option(
|
|
86
|
+
None, "--package-name", help="Override the derived Python package name."
|
|
87
|
+
),
|
|
88
|
+
description: str = typer.Option(None, "--description", help="One-line project description."),
|
|
89
|
+
output_dir: Path = typer.Option(
|
|
90
|
+
None, "--output-dir", "-o", help="Directory to create the project in (default: cwd)."
|
|
91
|
+
),
|
|
92
|
+
do_git: bool = typer.Option(True, "--git/--no-git", help="Initialise a git repository."),
|
|
93
|
+
do_install: bool = typer.Option(
|
|
94
|
+
True, "--install/--no-install", help="Run `uv sync` after scaffolding."
|
|
95
|
+
),
|
|
96
|
+
do_precommit: bool = typer.Option(
|
|
97
|
+
True, "--precommit/--no-precommit", help="Install pre-commit hooks."
|
|
98
|
+
),
|
|
99
|
+
force: bool = typer.Option(False, "--force", help="Overwrite a non-empty target directory."),
|
|
100
|
+
yes: bool = typer.Option(
|
|
101
|
+
False, "--yes", "-y", help="Accept all defaults; never prompt (CI / scripting)."
|
|
102
|
+
),
|
|
103
|
+
_version: bool = typer.Option(
|
|
104
|
+
None, "--version", callback=_version_callback, is_eager=True, help="Show version."
|
|
105
|
+
),
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Scaffold a production-ready Python MCP server."""
|
|
108
|
+
interactive = not yes
|
|
109
|
+
output_dir = output_dir or Path.cwd()
|
|
110
|
+
|
|
111
|
+
def choose(label: str, choices: list[str], default: str, current: str | None) -> str:
|
|
112
|
+
if current is not None:
|
|
113
|
+
return current
|
|
114
|
+
return _prompt_choice(label, choices, default) if interactive else default
|
|
115
|
+
|
|
116
|
+
if not project_name:
|
|
117
|
+
if interactive:
|
|
118
|
+
project_name = Prompt.ask("[bold]Project name[/bold]", default="my-mcp-server")
|
|
119
|
+
else:
|
|
120
|
+
err_console.print("[red]error:[/red] project name is required with --yes")
|
|
121
|
+
raise typer.Exit(code=2)
|
|
122
|
+
|
|
123
|
+
preset = choose("Preset", preset_choices(), DEFAULT_PRESET, preset)
|
|
124
|
+
transport = choose("Transport", TRANSPORTS, "streamable-http", transport)
|
|
125
|
+
auth = choose("Auth", AUTH_MODES, "none", auth)
|
|
126
|
+
|
|
127
|
+
if interactive:
|
|
128
|
+
do_git = Confirm.ask("Initialise a git repository?", default=do_git)
|
|
129
|
+
do_install = Confirm.ask("Install dependencies now (uv sync)?", default=do_install)
|
|
130
|
+
if do_git:
|
|
131
|
+
do_precommit = Confirm.ask("Install pre-commit hooks?", default=do_precommit)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
config = ProjectConfig(
|
|
135
|
+
project_name=project_name,
|
|
136
|
+
preset=preset,
|
|
137
|
+
transport=transport,
|
|
138
|
+
auth=auth,
|
|
139
|
+
description=description or "",
|
|
140
|
+
)
|
|
141
|
+
if package_name:
|
|
142
|
+
# Validate + override the derived package name.
|
|
143
|
+
config.package_name = to_package_name(package_name)
|
|
144
|
+
target = output_dir / project_name
|
|
145
|
+
generate(config, target, force=force)
|
|
146
|
+
except GeneratorError as exc:
|
|
147
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
148
|
+
raise typer.Exit(code=1) from exc
|
|
149
|
+
|
|
150
|
+
console.print()
|
|
151
|
+
console.print(
|
|
152
|
+
f"[green]✓[/green] Scaffolded [bold]{project_name}[/bold] "
|
|
153
|
+
f"[dim]({PRESETS[preset].title} · {transport} · auth: {auth})[/dim]"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if do_git:
|
|
157
|
+
_maybe_git_init(target)
|
|
158
|
+
if do_install:
|
|
159
|
+
_maybe_uv_sync(target)
|
|
160
|
+
if do_precommit:
|
|
161
|
+
_maybe_precommit(target)
|
|
162
|
+
|
|
163
|
+
_print_next_steps(config, target, output_dir)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_next_steps(config: ProjectConfig, target: Path, output_dir: Path) -> None:
|
|
167
|
+
rel = target.relative_to(output_dir) if target.is_relative_to(output_dir) else target
|
|
168
|
+
pkg = config.package_name
|
|
169
|
+
run_cmd = f"uv run {pkg}"
|
|
170
|
+
body = Text()
|
|
171
|
+
body.append("Next steps\n\n", style="bold")
|
|
172
|
+
body.append(f" cd {rel}\n")
|
|
173
|
+
body.append(" uv sync ", style="cyan")
|
|
174
|
+
body.append("# install dependencies\n", style="dim")
|
|
175
|
+
body.append(f" {run_cmd}{' ' * max(1, 17 - len(run_cmd))}", style="cyan")
|
|
176
|
+
body.append(f"# run the server ({config.transport})\n", style="dim")
|
|
177
|
+
body.append(" uv run pytest ", style="cyan")
|
|
178
|
+
body.append("# run the test suite\n\n", style="dim")
|
|
179
|
+
body.append("Inspect it with the MCP Inspector:\n")
|
|
180
|
+
body.append(f" npx @modelcontextprotocol/inspector uv run {pkg}\n", style="cyan")
|
|
181
|
+
if config.auth_enabled:
|
|
182
|
+
body.append("\nAuth is on. ", style="bold yellow")
|
|
183
|
+
body.append("Set OAUTH_* vars in .env (see .env.example) and point them at\n")
|
|
184
|
+
body.append("your identity provider (Keycloak / WorkOS / Auth0 / Azure).")
|
|
185
|
+
console.print(Panel(body, border_style="green", expand=False))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main() -> None:
|
|
189
|
+
typer.run(create)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
create_mcp/generator.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The scaffolding engine: turn a :class:`ProjectConfig` into a project on disk.
|
|
2
|
+
|
|
3
|
+
Templates live under ``templates/{base,presets/<preset>,auth}`` and are rendered
|
|
4
|
+
with Jinja using *non-default* delimiters (``[[ ]]`` / ``[% %]``) so the template
|
|
5
|
+
files can freely contain ``{{ }}`` and GitHub Actions ``${{ }}`` expressions
|
|
6
|
+
verbatim. Path segments are de-tokenised:
|
|
7
|
+
|
|
8
|
+
* ``__pkg__`` -> the Python package name
|
|
9
|
+
* ``dot-foo`` -> ``.foo`` (ship dotfiles without leading dots)
|
|
10
|
+
* trailing ``.jinja`` is stripped from the output filename
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import keyword
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import jinja2
|
|
22
|
+
|
|
23
|
+
from . import FASTMCP_TARGET, __version__
|
|
24
|
+
from . import templates as _templates
|
|
25
|
+
from .presets import PRESETS, preset_dirname
|
|
26
|
+
|
|
27
|
+
TEMPLATES_DIR = Path(_templates.__file__).parent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GeneratorError(Exception):
|
|
31
|
+
"""Raised when a project cannot be generated (bad name, target exists, ...)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ProjectConfig:
|
|
36
|
+
"""Everything needed to render a project."""
|
|
37
|
+
|
|
38
|
+
project_name: str
|
|
39
|
+
preset: str = "minimal"
|
|
40
|
+
transport: str = "streamable-http"
|
|
41
|
+
auth: str = "none" # "none" | "oauth"
|
|
42
|
+
description: str = ""
|
|
43
|
+
author: str = ""
|
|
44
|
+
python_version: str = "3.11"
|
|
45
|
+
|
|
46
|
+
# Derived / filled in __post_init__.
|
|
47
|
+
package_name: str = field(default="", init=False)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
self.package_name = to_package_name(self.project_name)
|
|
51
|
+
if self.preset not in PRESETS:
|
|
52
|
+
raise GeneratorError(
|
|
53
|
+
f"Unknown preset {self.preset!r}. Choose from: {', '.join(PRESETS)}"
|
|
54
|
+
)
|
|
55
|
+
if self.transport not in ("streamable-http", "stdio"):
|
|
56
|
+
raise GeneratorError(f"Unknown transport {self.transport!r}.")
|
|
57
|
+
if self.auth not in ("none", "oauth"):
|
|
58
|
+
raise GeneratorError(f"Unknown auth mode {self.auth!r}.")
|
|
59
|
+
if not self.description:
|
|
60
|
+
self.description = "A Model Context Protocol server, scaffolded with create-mcp."
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def auth_enabled(self) -> bool:
|
|
64
|
+
return self.auth == "oauth"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def http(self) -> bool:
|
|
68
|
+
return self.transport == "streamable-http"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def context(self) -> dict[str, object]:
|
|
72
|
+
"""The variables exposed to templates."""
|
|
73
|
+
return {
|
|
74
|
+
"project_name": self.project_name,
|
|
75
|
+
"package_name": self.package_name,
|
|
76
|
+
"preset": self.preset,
|
|
77
|
+
"transport": self.transport,
|
|
78
|
+
"http": self.http,
|
|
79
|
+
"auth": self.auth,
|
|
80
|
+
"auth_enabled": self.auth_enabled,
|
|
81
|
+
"description": self.description,
|
|
82
|
+
"author": self.author or "your name",
|
|
83
|
+
"python_version": self.python_version,
|
|
84
|
+
"fastmcp_target": FASTMCP_TARGET,
|
|
85
|
+
"create_mcp_version": __version__,
|
|
86
|
+
"year": datetime.date.today().year,
|
|
87
|
+
"extra_dependencies": list(PRESETS[self.preset].extra_dependencies),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def to_package_name(project_name: str) -> str:
|
|
92
|
+
"""Turn an arbitrary project name into a valid, importable package name."""
|
|
93
|
+
slug = project_name.strip().lower()
|
|
94
|
+
slug = re.sub(r"[^0-9a-z]+", "_", slug)
|
|
95
|
+
slug = re.sub(r"_+", "_", slug).strip("_")
|
|
96
|
+
if not slug:
|
|
97
|
+
raise GeneratorError(f"Cannot derive a package name from {project_name!r}.")
|
|
98
|
+
if slug[0].isdigit():
|
|
99
|
+
slug = f"_{slug}"
|
|
100
|
+
if keyword.iskeyword(slug):
|
|
101
|
+
slug = f"{slug}_"
|
|
102
|
+
return slug
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _layers(config: ProjectConfig) -> list[Path]:
|
|
106
|
+
"""The template directories to overlay, in order (later wins)."""
|
|
107
|
+
layers = [TEMPLATES_DIR / "base", TEMPLATES_DIR / "presets" / preset_dirname(config.preset)]
|
|
108
|
+
if config.auth_enabled:
|
|
109
|
+
layers.append(TEMPLATES_DIR / "auth")
|
|
110
|
+
return layers
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _detokenise(rel: Path, package_name: str) -> Path:
|
|
114
|
+
parts: list[str] = []
|
|
115
|
+
for seg in rel.parts:
|
|
116
|
+
if seg == "__pkg__":
|
|
117
|
+
seg = package_name
|
|
118
|
+
elif seg.startswith("dot-"):
|
|
119
|
+
seg = "." + seg[len("dot-") :]
|
|
120
|
+
if seg.endswith(".jinja"):
|
|
121
|
+
seg = seg[: -len(".jinja")]
|
|
122
|
+
parts.append(seg)
|
|
123
|
+
return Path(*parts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _make_env(searchpath: Path) -> jinja2.Environment:
|
|
127
|
+
return jinja2.Environment(
|
|
128
|
+
loader=jinja2.FileSystemLoader(str(searchpath)),
|
|
129
|
+
variable_start_string="[[",
|
|
130
|
+
variable_end_string="]]",
|
|
131
|
+
block_start_string="[%",
|
|
132
|
+
block_end_string="%]",
|
|
133
|
+
comment_start_string="[#",
|
|
134
|
+
comment_end_string="#]",
|
|
135
|
+
keep_trailing_newline=True,
|
|
136
|
+
trim_blocks=True,
|
|
137
|
+
lstrip_blocks=True,
|
|
138
|
+
undefined=jinja2.StrictUndefined,
|
|
139
|
+
autoescape=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def render_to_mapping(config: ProjectConfig) -> dict[str, str]:
|
|
144
|
+
"""Render every template to an in-memory ``{relative_path: content}`` mapping.
|
|
145
|
+
|
|
146
|
+
Kept separate from disk I/O so the generator is trivially unit-testable.
|
|
147
|
+
"""
|
|
148
|
+
context = config.context
|
|
149
|
+
files: dict[str, str] = {}
|
|
150
|
+
for layer in _layers(config):
|
|
151
|
+
if not layer.is_dir():
|
|
152
|
+
raise GeneratorError(f"Missing template layer: {layer}")
|
|
153
|
+
env = _make_env(layer)
|
|
154
|
+
for path in sorted(layer.rglob("*")):
|
|
155
|
+
if path.is_dir():
|
|
156
|
+
continue
|
|
157
|
+
rel = path.relative_to(layer)
|
|
158
|
+
template = env.get_template(rel.as_posix())
|
|
159
|
+
rendered = template.render(**context)
|
|
160
|
+
out_rel = _detokenise(rel, config.package_name).as_posix()
|
|
161
|
+
files[out_rel] = rendered
|
|
162
|
+
return files
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def generate(config: ProjectConfig, target_dir: Path, *, force: bool = False) -> Path:
|
|
166
|
+
"""Write the rendered project to ``target_dir`` and return the project root."""
|
|
167
|
+
target_dir = target_dir.resolve()
|
|
168
|
+
if target_dir.exists() and any(target_dir.iterdir()) and not force:
|
|
169
|
+
raise GeneratorError(
|
|
170
|
+
f"Target directory {target_dir} already exists and is not empty. "
|
|
171
|
+
"Use --force to overwrite."
|
|
172
|
+
)
|
|
173
|
+
files = render_to_mapping(config)
|
|
174
|
+
for rel, content in files.items():
|
|
175
|
+
dest = target_dir / rel
|
|
176
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
dest.write_text(content, encoding="utf-8")
|
|
178
|
+
return target_dir
|
create_mcp/presets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Preset definitions for the kind of MCP server to scaffold.
|
|
2
|
+
|
|
3
|
+
Each preset maps to a directory under ``templates/presets/<key>/`` that is
|
|
4
|
+
overlaid on top of ``templates/base/`` to provide a purpose-built starting set
|
|
5
|
+
of tools/resources/prompts. The default project layout is identical across
|
|
6
|
+
presets — only the example ``tools.py`` (and its declared extra dependencies)
|
|
7
|
+
differ.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Preset:
|
|
17
|
+
key: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str
|
|
20
|
+
# Extra runtime dependencies the generated project needs for this preset.
|
|
21
|
+
extra_dependencies: tuple[str, ...] = field(default_factory=tuple)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PRESETS: dict[str, Preset] = {
|
|
25
|
+
"minimal": Preset(
|
|
26
|
+
key="minimal",
|
|
27
|
+
title="Minimal",
|
|
28
|
+
description="A clean, typed server with one example tool, resource and prompt.",
|
|
29
|
+
),
|
|
30
|
+
"api-wrapper": Preset(
|
|
31
|
+
key="api-wrapper",
|
|
32
|
+
title="API wrapper",
|
|
33
|
+
description="Wrap an existing HTTP/JSON API as MCP tools, with typed models.",
|
|
34
|
+
extra_dependencies=("httpx>=0.27",),
|
|
35
|
+
),
|
|
36
|
+
"db": Preset(
|
|
37
|
+
key="db",
|
|
38
|
+
title="Database",
|
|
39
|
+
description="Expose a SQLite-backed store as typed MCP tools (CRUD example).",
|
|
40
|
+
),
|
|
41
|
+
"agent-tools": Preset(
|
|
42
|
+
key="agent-tools",
|
|
43
|
+
title="Agent tools",
|
|
44
|
+
description="A toolbox for autonomous agents: calculator, scratchpad memory, clock.",
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
DEFAULT_PRESET = "minimal"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def preset_dirname(key: str) -> str:
|
|
52
|
+
"""Template directory name for a preset key (hyphens -> underscores)."""
|
|
53
|
+
return key.replace("-", "_")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def preset_choices() -> list[str]:
|
|
57
|
+
return list(PRESETS)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Project templates rendered by the create-mcp generator.
|
|
2
|
+
|
|
3
|
+
This package only exists so that the template tree ships inside the wheel and
|
|
4
|
+
can be located at runtime via ``Path(create_mcp.templates.__file__).parent``.
|
|
5
|
+
The files under ``base/``, ``presets/`` and ``auth/`` are Jinja templates, not
|
|
6
|
+
importable Python.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""OAuth 2.1 resource-server wiring for [[ project_name ]].
|
|
2
|
+
|
|
3
|
+
This server validates JWT access tokens issued by an *external* Authorization
|
|
4
|
+
Server and advertises its requirements via RFC 9728 Protected Resource Metadata
|
|
5
|
+
(``/.well-known/oauth-protected-resource``). Unauthenticated requests get a 401
|
|
6
|
+
with a ``WWW-Authenticate`` challenge pointing at that metadata, so MCP clients
|
|
7
|
+
can discover the authorization server and complete the OAuth flow automatically.
|
|
8
|
+
|
|
9
|
+
Point the ``MCP_OAUTH_*`` settings at your IdP (Keycloak, WorkOS AuthKit, Auth0,
|
|
10
|
+
Microsoft Entra, ...). The defaults are placeholders that let the server boot
|
|
11
|
+
and serve discovery metadata offline.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from fastmcp.server.auth import RemoteAuthProvider
|
|
17
|
+
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
18
|
+
from pydantic import AnyHttpUrl
|
|
19
|
+
|
|
20
|
+
from .settings import Settings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_auth(settings: Settings) -> RemoteAuthProvider:
|
|
24
|
+
"""Construct the resource-server auth provider from settings."""
|
|
25
|
+
token_verifier = JWTVerifier(
|
|
26
|
+
jwks_uri=settings.oauth_jwks_uri,
|
|
27
|
+
issuer=settings.oauth_issuer,
|
|
28
|
+
audience=settings.oauth_audience,
|
|
29
|
+
)
|
|
30
|
+
return RemoteAuthProvider(
|
|
31
|
+
token_verifier=token_verifier,
|
|
32
|
+
authorization_servers=[AnyHttpUrl(settings.oauth_issuer)],
|
|
33
|
+
base_url=settings.base_url,
|
|
34
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Tests for the OAuth 2.1 resource-server behaviour.
|
|
2
|
+
|
|
3
|
+
These drive the ASGI app directly (via httpx's ASGITransport) so they are fast
|
|
4
|
+
and deterministic — no real server, no network, no external identity provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
|
|
13
|
+
from [[ package_name ]].server import mcp
|
|
14
|
+
|
|
15
|
+
# RFC 9728 Protected Resource Metadata is served at the resource-path-aware
|
|
16
|
+
# location; the MCP endpoint is mounted at /mcp.
|
|
17
|
+
PRM_PATH = "/.well-known/oauth-protected-resource/mcp"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def http_app() -> Starlette:
|
|
22
|
+
return mcp.http_app()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def test_protected_resource_metadata_is_served(http_app: Starlette) -> None:
|
|
26
|
+
transport = httpx.ASGITransport(app=http_app)
|
|
27
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
28
|
+
response = await client.get(PRM_PATH)
|
|
29
|
+
assert response.status_code == 200
|
|
30
|
+
data = response.json()
|
|
31
|
+
assert data["resource"]
|
|
32
|
+
assert data["authorization_servers"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_unauthenticated_request_is_rejected(http_app: Starlette) -> None:
|
|
36
|
+
transport = httpx.ASGITransport(app=http_app)
|
|
37
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
38
|
+
response = await client.post(
|
|
39
|
+
"/mcp",
|
|
40
|
+
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
|
41
|
+
headers={"Accept": "application/json, text/event-stream"},
|
|
42
|
+
)
|
|
43
|
+
assert response.status_code == 401
|
|
44
|
+
assert "WWW-Authenticate" in response.headers
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
#
|
|
3
|
+
# uv-based image for [[ project_name ]]. Builds the locked environment and runs
|
|
4
|
+
# the MCP server over Streamable HTTP. (uv.lock is optional here — it is picked
|
|
5
|
+
# up automatically if you have committed it, which is recommended.)
|
|
6
|
+
FROM ghcr.io/astral-sh/uv:python[[ python_version ]]-bookworm-slim
|
|
7
|
+
|
|
8
|
+
ENV UV_COMPILE_BYTECODE=1 \
|
|
9
|
+
UV_LINK_MODE=copy \
|
|
10
|
+
UV_PYTHON_DOWNLOADS=0
|
|
11
|
+
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
|
|
14
|
+
# Install dependencies first for better layer caching.
|
|
15
|
+
COPY pyproject.toml uv.lock* ./
|
|
16
|
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
17
|
+
uv sync --no-dev --no-install-project
|
|
18
|
+
|
|
19
|
+
# Then install the project itself.
|
|
20
|
+
COPY . .
|
|
21
|
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
22
|
+
uv sync --no-dev
|
|
23
|
+
|
|
24
|
+
ENV PATH="/app/.venv/bin:$PATH" \
|
|
25
|
+
MCP_TRANSPORT=http \
|
|
26
|
+
MCP_HOST=0.0.0.0 \
|
|
27
|
+
MCP_PORT=8000
|
|
28
|
+
|
|
29
|
+
EXPOSE 8000
|
|
30
|
+
|
|
31
|
+
# Runs the console script defined in pyproject ([[ package_name ]]).
|
|
32
|
+
CMD ["[[ package_name ]]"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [[ year ]] [[ author ]]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|