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.
Files changed (38) hide show
  1. create_mcp/__init__.py +10 -0
  2. create_mcp/__main__.py +8 -0
  3. create_mcp/cli.py +193 -0
  4. create_mcp/generator.py +178 -0
  5. create_mcp/presets.py +57 -0
  6. create_mcp/templates/__init__.py +7 -0
  7. create_mcp/templates/auth/src/__pkg__/auth.py.jinja +34 -0
  8. create_mcp/templates/auth/tests/test_auth.py.jinja +44 -0
  9. create_mcp/templates/base/Dockerfile.jinja +32 -0
  10. create_mcp/templates/base/LICENSE.jinja +21 -0
  11. create_mcp/templates/base/README.md.jinja +122 -0
  12. create_mcp/templates/base/dot-dockerignore.jinja +13 -0
  13. create_mcp/templates/base/dot-env.example.jinja +21 -0
  14. create_mcp/templates/base/dot-github/workflows/ci.yml.jinja +32 -0
  15. create_mcp/templates/base/dot-gitignore.jinja +29 -0
  16. create_mcp/templates/base/dot-pre-commit-config.yaml.jinja +15 -0
  17. create_mcp/templates/base/pyproject.toml.jinja +58 -0
  18. create_mcp/templates/base/src/__pkg__/__init__.py.jinja +3 -0
  19. create_mcp/templates/base/src/__pkg__/__main__.py.jinja +17 -0
  20. create_mcp/templates/base/src/__pkg__/app.py.jinja +31 -0
  21. create_mcp/templates/base/src/__pkg__/server.py.jinja +30 -0
  22. create_mcp/templates/base/src/__pkg__/settings.py.jinja +30 -0
  23. create_mcp/templates/base/tests/__init__.py.jinja +0 -0
  24. create_mcp/templates/base/tests/conftest.py.jinja +18 -0
  25. create_mcp/templates/base/tests/test_server.py.jinja +19 -0
  26. create_mcp/templates/presets/agent_tools/src/__pkg__/tools.py.jinja +73 -0
  27. create_mcp/templates/presets/agent_tools/tests/test_tools.py.jinja +45 -0
  28. create_mcp/templates/presets/api_wrapper/src/__pkg__/tools.py.jinja +41 -0
  29. create_mcp/templates/presets/api_wrapper/tests/test_tools.py.jinja +29 -0
  30. create_mcp/templates/presets/db/src/__pkg__/tools.py.jinja +79 -0
  31. create_mcp/templates/presets/db/tests/test_tools.py.jinja +29 -0
  32. create_mcp/templates/presets/minimal/src/__pkg__/tools.py.jinja +40 -0
  33. create_mcp/templates/presets/minimal/tests/test_tools.py.jinja +25 -0
  34. create_mcp-0.1.0.dist-info/METADATA +157 -0
  35. create_mcp-0.1.0.dist-info/RECORD +38 -0
  36. create_mcp-0.1.0.dist-info/WHEEL +4 -0
  37. create_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  38. 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
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m create_mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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()
@@ -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.