python-mcp-bootstrap 0.1.3__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,58 @@
1
+ """Shared, stdlib-only bootstrap for Python MCP applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .core import (
6
+ CARD,
7
+ PIN,
8
+ PIN_FILE,
9
+ PYTHON_DIR,
10
+ REPAIR_CARD,
11
+ REQUIRED,
12
+ UV_INSTALL,
13
+ VENV_DIR,
14
+ BootstrapSpec,
15
+ Exec,
16
+ Main,
17
+ Probe,
18
+ Runner,
19
+ bootstrap,
20
+ find_uv,
21
+ floor_text,
22
+ format_version,
23
+ interpreter_version,
24
+ provision,
25
+ required_text,
26
+ run_command,
27
+ venv_python,
28
+ version_tuple,
29
+ )
30
+
31
+ __version__ = "0.1.3"
32
+
33
+ __all__ = [
34
+ "CARD",
35
+ "PIN",
36
+ "PIN_FILE",
37
+ "PYTHON_DIR",
38
+ "REPAIR_CARD",
39
+ "REQUIRED",
40
+ "UV_INSTALL",
41
+ "VENV_DIR",
42
+ "BootstrapSpec",
43
+ "Exec",
44
+ "Main",
45
+ "Probe",
46
+ "Runner",
47
+ "__version__",
48
+ "bootstrap",
49
+ "find_uv",
50
+ "floor_text",
51
+ "format_version",
52
+ "interpreter_version",
53
+ "provision",
54
+ "required_text",
55
+ "run_command",
56
+ "venv_python",
57
+ "version_tuple",
58
+ ]
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m python_mcp_bootstrap``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,57 @@
1
+ """Command-line diagnostics for the bootstrap contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .core import PIN, REQUIRED, find_uv, required_text
11
+
12
+
13
+ def _parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="python-mcp-bootstrap",
16
+ description="Inspect the pre-install Python bootstrap contract.",
17
+ )
18
+ parser.add_argument(
19
+ "--check",
20
+ action="store_true",
21
+ help="print the bootstrap pin and current selection facts as JSON",
22
+ )
23
+ parser.add_argument(
24
+ "--version",
25
+ action="version",
26
+ version=f"%(prog)s {__version__}",
27
+ )
28
+ return parser
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ """Run the diagnostic CLI.
33
+
34
+ This command is intentionally diagnostic: it never invokes uv, pip, or a
35
+ subprocess. Application first-run wrappers call the library API instead.
36
+ """
37
+
38
+ parser = _parser()
39
+ args = parser.parse_args(argv)
40
+ if not args.check:
41
+ parser.print_help()
42
+ return 2
43
+
44
+ payload = {
45
+ "ok": True,
46
+ "version": __version__,
47
+ "required": f">={required_text(REQUIRED)}",
48
+ "pin": PIN,
49
+ "uv": find_uv(),
50
+ }
51
+ json.dump(payload, sys.stdout, indent=2, sort_keys=True)
52
+ sys.stdout.write("\n")
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,729 @@
1
+ """Core contract for the pre-install Python bootstrap.
2
+
3
+ This module is deliberately stdlib-only and importable on Python 3.12. The
4
+ package metadata requires 3.14, but consumers copy this file into a first-run
5
+ entry point that may execute before the application's dependencies exist.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path, PureWindowsPath
19
+ from typing import Any
20
+
21
+ Version = tuple[int, int, int]
22
+ Main = Callable[[list[str]], int]
23
+ Runner = Callable[[list[str], Path, dict[str, str], int], dict[str, Any]]
24
+ Probe = Callable[[str], Version | None]
25
+ Exec = Callable[[str, list[str], dict[str, str]], int | None]
26
+
27
+ REQUIRED: Version = (3, 14, 0)
28
+ PIN = "3.15.2"
29
+ VENV_DIR = ".venv"
30
+ PYTHON_DIR = ".uv-python"
31
+ PIN_FILE = ".python-version"
32
+ UV_INSTALL = (
33
+ "uv is not installed. Install it from the official instructions, then "
34
+ "re-run with --install-python: "
35
+ "https://docs.astral.sh/uv/getting-started/installation/"
36
+ )
37
+ CARD_TEMPLATE = (
38
+ "Set up needs a project-local Python {pin} on this computer (one-time "
39
+ "download). Your system Python is not changed; a mismatched .venv is "
40
+ "moved aside. Continue?"
41
+ )
42
+ CARD = CARD_TEMPLATE.format(pin=PIN)
43
+ REPAIR_CARD = (
44
+ "The project environment is missing the app or MCP. Re-run with "
45
+ "--install-python to repair it. Nothing has been downloaded. Continue?"
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class BootstrapSpec:
51
+ """Application-specific settings for one bootstrap entry point."""
52
+
53
+ app: str
54
+ entrypoint: str = "first_run.py"
55
+ env_var: str = "MCP_BOOTSTRAP_INTERPRETER"
56
+ package_main: Main | None = None
57
+ required: Version = REQUIRED
58
+ pin: str = PIN
59
+ install_args: tuple[str, ...] = ("-e", ".")
60
+ verify_import: str | None = None
61
+ card: str = CARD
62
+ venv_dir: str = VENV_DIR
63
+ python_dir: str = PYTHON_DIR
64
+ pin_file: str = PIN_FILE
65
+ uv_install: str = UV_INSTALL
66
+ repair_card: str = REPAIR_CARD
67
+
68
+ def __post_init__(self) -> None:
69
+ if not self.app.strip():
70
+ raise ValueError("app must not be empty")
71
+ if not self.entrypoint.strip():
72
+ raise ValueError("entrypoint must not be empty")
73
+ if not self.env_var.strip():
74
+ raise ValueError("env_var must not be empty")
75
+ if not self.venv_dir.strip() or not self.python_dir.strip() or not self.pin_file.strip():
76
+ raise ValueError("venv_dir, python_dir, and pin_file must not be empty")
77
+ for field, value in (
78
+ ("entrypoint", self.entrypoint),
79
+ ("venv_dir", self.venv_dir),
80
+ ("python_dir", self.python_dir),
81
+ ("pin_file", self.pin_file),
82
+ ):
83
+ _require_project_path(field, value)
84
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", self.env_var) is None:
85
+ raise ValueError(f"env_var is not a valid environment name: {self.env_var!r}")
86
+ if len(self.required) != 3 or any(not isinstance(part, int) for part in self.required):
87
+ raise ValueError(f"required is not a version: {self.required!r}")
88
+ if re.fullmatch(r"\d+(?:\.\d+){0,2}", self.pin) is None:
89
+ raise ValueError(f"pin must be a plain version: {self.pin!r}")
90
+ pin_version = version_tuple(self.pin)
91
+ if pin_version is None:
92
+ raise ValueError(f"pin is not a version: {self.pin!r}")
93
+ if pin_version < self.required:
94
+ raise ValueError(f"pin {self.pin!r} is older than required {self.required!r}")
95
+ if self.verify_import is not None and not _valid_module(self.verify_import):
96
+ raise ValueError(f"verify_import is not a module: {self.verify_import!r}")
97
+ _require_project_install_args(self.install_args)
98
+ if not self.repair_card.strip():
99
+ raise ValueError("repair_card must not be empty")
100
+ if self.card == CARD and self.pin != PIN:
101
+ object.__setattr__(self, "card", CARD_TEMPLATE.format(pin=self.pin))
102
+
103
+
104
+ def _valid_module(value: str) -> bool:
105
+ parts = value.split(".")
106
+ return bool(parts) and all(part.isidentifier() for part in parts)
107
+
108
+
109
+ def _escapes_project(value: str) -> bool:
110
+ """True when ``value`` names a path outside the project root.
111
+
112
+ ``PureWindowsPath`` is checked on every platform: a Unix-style rooted
113
+ path such as ``/etc/passwd`` is not absolute on Windows and would
114
+ otherwise pass ``Path.is_absolute()``.
115
+ """
116
+
117
+ posix = Path(value)
118
+ windows = PureWindowsPath(value)
119
+ return (
120
+ posix.is_absolute()
121
+ or windows.is_absolute()
122
+ or bool(windows.root)
123
+ or ".." in posix.parts
124
+ or ".." in windows.parts
125
+ )
126
+
127
+
128
+ def _require_project_path(field: str, value: str) -> None:
129
+ if _escapes_project(value):
130
+ raise ValueError(f"{field} must stay inside the project: {value!r}")
131
+
132
+
133
+ _RESERVED_LONG_FLAGS = frozenset({"--target", "--prefix", "--root", "--python"})
134
+ _RESERVED_SHORT_FLAGS = frozenset({"t", "p"})
135
+ _VALUE_SHORT_FLAGS = frozenset({"e", "r", "c", "f", "i", "C"})
136
+
137
+
138
+ def _short_flag_problem(value: str) -> bool:
139
+ """True when a short token redirects the install or embeds a path.
140
+
141
+ A cluster such as ``-vt/tmp`` means ``-v -t /tmp``, so checking only the
142
+ first letter is not enough. Walk the cluster until a value-taking option.
143
+ """
144
+
145
+ cluster = value[1:]
146
+ if "://" not in value and ("/" in cluster or "\\" in cluster or ".." in cluster):
147
+ return True
148
+ for char in cluster:
149
+ if char in _RESERVED_SHORT_FLAGS:
150
+ return True
151
+ if char in _VALUE_SHORT_FLAGS:
152
+ return False
153
+ return False
154
+
155
+
156
+ def _require_project_install_args(values: object) -> None:
157
+ """Keep consumer-supplied install arguments inside the project.
158
+
159
+ A bare flag is passed through, but a path-like value is rejected whether
160
+ it is a separate entry (``-e ../app``), embedded in a long flag
161
+ (``--find-links=/tmp/wheels``), or attached to a short flag (``-t/tmp``).
162
+ Flags that redirect the install away from the project venv
163
+ (``--target``, ``--prefix``, ``--root``, ``--python`` and their short
164
+ forms) are rejected. Non-path flags such as ``--index-url=...`` are left
165
+ alone; credentials in them are redacted before reporting.
166
+ """
167
+
168
+ if isinstance(values, str) or not isinstance(values, (tuple, list)):
169
+ raise ValueError(f"install_args must be a sequence of strings: {values!r}")
170
+ for value in values:
171
+ if not isinstance(value, str):
172
+ raise ValueError(f"install_args must be strings: {value!r}")
173
+ if value.startswith("-"):
174
+ if value.startswith("--"):
175
+ name = value.split("=", 1)[0]
176
+ if name in _RESERVED_LONG_FLAGS:
177
+ raise ValueError(f"install_args must not redirect the install: {value!r}")
178
+ embedded = value.split("=")[1:]
179
+ else:
180
+ if _short_flag_problem(value):
181
+ raise ValueError(
182
+ f"install_args must not redirect or escape the project: {value!r}"
183
+ )
184
+ embedded = []
185
+ if any(_escapes_project(part) for part in embedded):
186
+ raise ValueError(f"install_args must stay inside the project: {value!r}")
187
+ continue
188
+ if _escapes_project(value):
189
+ raise ValueError(f"install_args must stay inside the project: {value!r}")
190
+
191
+
192
+ def version_tuple(text: object) -> Version | None:
193
+ """Parse ``3.15.2`` or ``Python 3.15.2`` into a three-part version.
194
+
195
+ Pre-release suffixes (``rc``, ``a``, ``b``, ``dev``) return ``None`` so a
196
+ release candidate never satisfies the final-release floor. A ``.post``
197
+ or ``+local`` suffix is accepted as the same final version.
198
+ """
199
+
200
+ raw = str(text).strip()
201
+ if raw.casefold().startswith("python"):
202
+ raw = raw[6:].strip()
203
+ if not raw:
204
+ return None
205
+ match = re.fullmatch(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)", raw)
206
+ if match is None:
207
+ return None
208
+ major, minor, patch, suffix = match.groups()
209
+ if suffix and not (suffix.startswith("+") or suffix.startswith(".post")):
210
+ return None
211
+ return (int(major), int(minor or 0), int(patch or 0))
212
+
213
+
214
+ def format_version(found: Version) -> str:
215
+ """Render a parsed version as ``major.minor.patch``."""
216
+
217
+ return ".".join(str(part) for part in found)
218
+
219
+
220
+ def required_text(required: Version = REQUIRED) -> str:
221
+ """Render the full floor version, including the patch component."""
222
+
223
+ return format_version(required)
224
+
225
+
226
+ def floor_text(required: Version = REQUIRED) -> str:
227
+ """Render the human-facing floor, dropping a zero patch component."""
228
+
229
+ major, minor, patch = required
230
+ return f"{major}.{minor}" if patch == 0 else format_version(required)
231
+
232
+
233
+ def venv_python(root: Path, venv_dir: str = VENV_DIR) -> Path:
234
+ """The interpreter a project-local venv writes on this platform."""
235
+
236
+ if os.name == "nt":
237
+ return Path(root) / venv_dir / "Scripts" / "python.exe"
238
+ return Path(root) / venv_dir / "bin" / "python"
239
+
240
+
241
+ def find_uv() -> str | None:
242
+ """Find uv without installing or downloading anything."""
243
+
244
+ found = shutil.which("uv")
245
+ if found:
246
+ return found
247
+
248
+ home = Path.home()
249
+ if os.name == "nt":
250
+ candidates = [
251
+ home / ".local" / "bin" / "uv.exe",
252
+ home / ".cargo" / "bin" / "uv.exe",
253
+ ]
254
+ local = os.environ.get("LOCALAPPDATA")
255
+ if local:
256
+ candidates.extend(
257
+ [
258
+ Path(local) / "uv" / "uv.exe",
259
+ Path(local) / "Microsoft" / "WinGet" / "Links" / "uv.exe",
260
+ ]
261
+ )
262
+ else:
263
+ candidates = [
264
+ home / ".local" / "bin" / "uv",
265
+ home / ".cargo" / "bin" / "uv",
266
+ ]
267
+
268
+ for candidate in candidates:
269
+ if candidate.is_file() and os.access(candidate, os.X_OK):
270
+ return str(candidate)
271
+ return None
272
+
273
+
274
+ _CREDENTIAL_URL = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*://)[^/\s:@]+(?::[^/\s@]*)?@", re.IGNORECASE)
275
+ _SECRET_FIELD = re.compile(
276
+ r"(?i)([\"']?\b(?:[A-Za-z0-9]+[_-])?"
277
+ r"(?:token|api[_-]?key|password|passwd|secret|private[_-]?key)\b[\"']?)"
278
+ r"(\s*[=:]\s*)"
279
+ r"(\"[^\"]*\"|'[^']*'|\S+)"
280
+ )
281
+ _BEARER_TOKEN = re.compile(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{8,}")
282
+
283
+
284
+ def _redact(text: str) -> str:
285
+ """Mask credentials that a tool may echo into diagnostics.
286
+
287
+ Covers URL userinfo, ``key=value`` / ``"key": "value"`` pairs, and
288
+ ``Authorization: Bearer`` headers. Over-redacting is preferred to leaking.
289
+ """
290
+
291
+ text = _CREDENTIAL_URL.sub(r"\1***:***@", text)
292
+ text = _BEARER_TOKEN.sub(r"\1***", text)
293
+ return _SECRET_FIELD.sub(r"\1\2***", text)
294
+
295
+
296
+ def run_command(command: list[str], cwd: Path, env: dict[str, str], timeout: int) -> dict[str, Any]:
297
+ """Run one provisioning step. Never raises for a missing or slow tool."""
298
+
299
+ if not command:
300
+ return {"returncode": 2, "stdout": "", "stderr": "empty command"}
301
+
302
+ try:
303
+ done = subprocess.run(
304
+ command,
305
+ cwd=str(cwd),
306
+ env=env,
307
+ capture_output=True,
308
+ text=True,
309
+ encoding="utf-8",
310
+ errors="replace",
311
+ timeout=timeout,
312
+ check=False,
313
+ )
314
+ except (OSError, subprocess.SubprocessError) as exc:
315
+ return {"returncode": 1, "stdout": "", "stderr": _redact(str(exc))[-2000:]}
316
+ return {
317
+ "returncode": done.returncode,
318
+ "stdout": _redact(done.stdout or "")[-2000:],
319
+ "stderr": _redact(done.stderr or "")[-2000:],
320
+ }
321
+
322
+
323
+ def interpreter_version(exe: str | Path) -> Version | None:
324
+ """Version of another interpreter, without importing the application."""
325
+
326
+ path = Path(exe)
327
+ if not path.is_file():
328
+ return None
329
+ result = run_command(
330
+ [str(path), "-c", "import sys; print(sys.version.split()[0])"],
331
+ path.parent,
332
+ dict(os.environ),
333
+ 30,
334
+ )
335
+ if result.get("returncode") != 0:
336
+ return None
337
+ return version_tuple(result.get("stdout", ""))
338
+
339
+
340
+ def _move_aside(path: Path) -> Path:
341
+ """Keep a venv from another version or a broken run instead of deleting it."""
342
+
343
+ backup = path.with_name(path.name + ".previous")
344
+ index = 1
345
+ while backup.exists():
346
+ backup = path.with_name(path.name + f".previous-{index}")
347
+ index += 1
348
+ path.rename(backup)
349
+ return backup
350
+
351
+
352
+ def _verify_command(python: Path, spec: BootstrapSpec) -> list[str] | None:
353
+ module = spec.verify_import
354
+ if module is None:
355
+ return None
356
+ code = f"import importlib; importlib.import_module({module!r})"
357
+ # ``-I`` implies ``-P`` and also drops PYTHONPATH/user site: only the target
358
+ # interpreter's own site-packages may satisfy the import. Without this, a
359
+ # flat package at the project root imports through the cwd on sys.path, the
360
+ # check passes, and the repair install never runs.
361
+ return [str(python), "-I", "-P", "-c", code]
362
+
363
+
364
+ def _installed(
365
+ python: Path,
366
+ spec: BootstrapSpec,
367
+ root: Path,
368
+ env: dict[str, str],
369
+ run: Runner,
370
+ ) -> bool:
371
+ command = _verify_command(python, spec)
372
+ if command is None:
373
+ return True
374
+ result = run(command, root, env, 60)
375
+ return result.get("returncode") == 0
376
+
377
+
378
+ def _step(command: list[str], result: dict[str, Any]) -> dict[str, Any]:
379
+ return {
380
+ "command": [_redact(part) for part in command],
381
+ "returncode": result.get("returncode"),
382
+ "stderr": _redact(str(result.get("stderr", "")))[-2000:],
383
+ }
384
+
385
+
386
+ def _failed(reason: str, spec: BootstrapSpec, **extra: object) -> dict[str, Any]:
387
+ payload: dict[str, Any] = {
388
+ "ok": False,
389
+ "app": spec.app,
390
+ "reason": reason,
391
+ "card": spec.card,
392
+ }
393
+ payload.update(extra)
394
+ return payload
395
+
396
+
397
+ def _needs_install(python: Path, spec: BootstrapSpec, found: Version) -> dict[str, Any]:
398
+ return {
399
+ "ok": False,
400
+ "installed": False,
401
+ "app": spec.app,
402
+ "reason": "needs_install",
403
+ "interpreter": str(python),
404
+ "python_version": format_version(found),
405
+ "required": f">={floor_text(spec.required)}",
406
+ "pin": spec.pin,
407
+ "needs_consent": True,
408
+ "card": spec.repair_card,
409
+ "next": f"python {spec.entrypoint} --install-python",
410
+ }
411
+
412
+
413
+ def provision(
414
+ root: Path,
415
+ spec: BootstrapSpec,
416
+ *,
417
+ uv: str | None = None,
418
+ run: Runner = run_command,
419
+ probe: Probe = interpreter_version,
420
+ ) -> dict[str, Any]:
421
+ """Provision the pinned interpreter, venv, and application install.
422
+
423
+ The caller is responsible for obtaining consent first. This function never
424
+ prompts and never installs uv; a missing uv is returned as ``needs_uv``.
425
+ """
426
+
427
+ root = Path(root)
428
+ uv = uv or find_uv()
429
+ if not uv:
430
+ return _failed("needs_uv", spec, tip=spec.uv_install)
431
+
432
+ python = venv_python(root, spec.venv_dir)
433
+ venv_path = root / spec.venv_dir
434
+ env = dict(os.environ)
435
+ env["UV_PYTHON_INSTALL_DIR"] = str(root / spec.python_dir)
436
+ env["UV_PYTHON_DOWNLOADS"] = "automatic"
437
+ env["UV_PYTHON_PREFERENCE"] = "only-managed"
438
+ env["UV_NO_PROGRESS"] = "1"
439
+ steps: list[dict[str, Any]] = []
440
+
441
+ need_venv = True
442
+ if python.is_file():
443
+ found = probe(str(python))
444
+ if found and found >= spec.required:
445
+ need_venv = False
446
+ if _installed(python, spec, root, env, run):
447
+ return {
448
+ "ok": True,
449
+ "app": spec.app,
450
+ "reason": None,
451
+ "interpreter": str(python),
452
+ "python_version": format_version(found),
453
+ "ran_install": False,
454
+ "installed": True,
455
+ "steps": steps,
456
+ }
457
+ else:
458
+ try:
459
+ _move_aside(venv_path)
460
+ except OSError as exc:
461
+ return _failed("venv_locked", spec, detail=str(exc), steps=steps)
462
+ elif venv_path.exists():
463
+ try:
464
+ _move_aside(venv_path)
465
+ except OSError as exc:
466
+ return _failed("venv_locked", spec, detail=str(exc), steps=steps)
467
+
468
+ if need_venv:
469
+ for command, timeout in (
470
+ ([uv, "python", "install", spec.pin], 600),
471
+ ([uv, "venv", spec.venv_dir, "--python", spec.pin, "--seed"], 300),
472
+ ):
473
+ result = run(command, root, env, timeout)
474
+ steps.append(_step(command, result))
475
+ if result.get("returncode") != 0:
476
+ return _failed("provision_failed", spec, steps=steps)
477
+
478
+ install_command = [
479
+ uv,
480
+ "pip",
481
+ "install",
482
+ *spec.install_args,
483
+ "--python",
484
+ str(python),
485
+ ]
486
+ result = run(install_command, root, env, 600)
487
+ steps.append(_step(install_command, result))
488
+ if result.get("returncode") != 0:
489
+ return _failed("provision_failed", spec, steps=steps)
490
+
491
+ verify_command = _verify_command(python, spec)
492
+ if verify_command is not None:
493
+ result = run(verify_command, root, env, 60)
494
+ steps.append(_step(verify_command, result))
495
+ if result.get("returncode") != 0:
496
+ return _failed("provision_failed", spec, steps=steps)
497
+
498
+ found = probe(str(python))
499
+ if not found or found < spec.required:
500
+ return _failed("provision_failed", spec, steps=steps, python_version=None)
501
+
502
+ # Only a run that installed the pin may advance ``.python-version``. On a
503
+ # reuse/repair path the existing interpreter is already good, so writing
504
+ # ``spec.pin`` would point a future offline run at an interpreter that was
505
+ # never downloaded.
506
+ if need_venv:
507
+ pin_path = root / spec.pin_file
508
+ wanted = spec.pin + "\n"
509
+ try:
510
+ if not pin_path.is_file() or pin_path.read_text(encoding="utf-8") != wanted:
511
+ temporary = pin_path.with_name(pin_path.name + ".tmp")
512
+ temporary.write_text(wanted, encoding="utf-8")
513
+ os.replace(temporary, pin_path)
514
+ except (OSError, UnicodeError) as exc:
515
+ return _failed("pin_write_failed", spec, detail=str(exc), steps=steps)
516
+
517
+ return {
518
+ "ok": True,
519
+ "app": spec.app,
520
+ "reason": None,
521
+ "interpreter": str(python),
522
+ "python_version": format_version(found),
523
+ "ran_install": True,
524
+ "installed": True,
525
+ "steps": steps,
526
+ }
527
+
528
+
529
+ def _report(payload: dict[str, Any], code: int) -> int:
530
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
531
+ return code
532
+
533
+
534
+ def _default_exec(path: str, argv: list[str], env: dict[str, str]) -> int | None:
535
+ """Replace the process on POSIX; wait for the child on Windows."""
536
+
537
+ if os.name == "nt":
538
+ return int(subprocess.call(argv, env=env))
539
+ os.execve(path, argv, env)
540
+
541
+
542
+ def _exec(
543
+ exec_fn: Exec,
544
+ python: Path,
545
+ root: Path,
546
+ spec: BootstrapSpec,
547
+ args: list[str],
548
+ ) -> int:
549
+ """Replace this process with the chosen interpreter, same script and args."""
550
+
551
+ env = dict(os.environ)
552
+ env[spec.env_var] = str(python)
553
+ argv = [str(python), str(Path(root) / spec.entrypoint), *args]
554
+ result = exec_fn(str(python), argv, env)
555
+ return int(result or 0)
556
+
557
+
558
+ def _too_old(found: str | None, spec: BootstrapSpec, **extra: object) -> dict[str, Any]:
559
+ payload: dict[str, Any] = {
560
+ "ok": False,
561
+ "installed": False,
562
+ "app": spec.app,
563
+ "reason": "python_too_old",
564
+ "found": found,
565
+ "required": f">={floor_text(spec.required)}",
566
+ "pin": spec.pin,
567
+ }
568
+ payload.update(extra)
569
+ return payload
570
+
571
+
572
+ def _handed_off(spec: BootstrapSpec) -> bool:
573
+ """True when a previous handoff recorded a usable interpreter.
574
+
575
+ ``spec.env_var`` is the handoff signal. A consumer may branch on it and
576
+ continue without calling ``bootstrap()`` again, so the first pass is
577
+ allowed to exec once to set it. Every later pass sees it set, which is
578
+ what keeps a consumer that does not branch from looping.
579
+ """
580
+
581
+ recorded = os.environ.get(spec.env_var, "").strip()
582
+ return bool(recorded) and os.path.isfile(recorded)
583
+
584
+
585
+ def _missing_continuation(spec: BootstrapSpec) -> dict[str, Any]:
586
+ """Report a handoff that has nowhere to continue.
587
+
588
+ Reached only after ``spec.env_var`` is already set: the interpreter has
589
+ been chosen, the entry point has been relaunched, and no continuation is
590
+ configured to take over. Exec'ing again would be the same process with the
591
+ same arguments, so this is an error, not a handoff.
592
+ """
593
+
594
+ return {
595
+ "ok": False,
596
+ "installed": False,
597
+ "app": spec.app,
598
+ "reason": "missing_continuation",
599
+ "interpreter": sys.executable,
600
+ "required": f">={floor_text(spec.required)}",
601
+ "pin": spec.pin,
602
+ "env_var": spec.env_var,
603
+ "handed_off": _handed_off(spec),
604
+ "detail": (
605
+ "the running interpreter already meets the floor and no continuation "
606
+ "is configured; pass BootstrapSpec.package_main (or main_fn) so setup "
607
+ "can continue in-process instead of re-executing this entry point"
608
+ ),
609
+ }
610
+
611
+
612
+ def bootstrap(
613
+ args: list[str],
614
+ root: Path,
615
+ spec: BootstrapSpec,
616
+ *,
617
+ exec_fn: Exec | None = None,
618
+ run: Runner = run_command,
619
+ uv: str | None = None,
620
+ probe: Probe = interpreter_version,
621
+ current: Version | None = None,
622
+ main_fn: Main | None = None,
623
+ ) -> int:
624
+ """Decide which interpreter runs the install, then hand over to it.
625
+
626
+ Order: explicit ``--python``, the running interpreter, an existing good
627
+ ``.venv``, then the consent card. Only a real download needs consent.
628
+ """
629
+
630
+ exec_fn = exec_fn or _default_exec
631
+ root = Path(root)
632
+ cleaned: list[str] = []
633
+ override: str | None = None
634
+ consent = False
635
+ index = 0
636
+ while index < len(args):
637
+ token = args[index]
638
+ if token == "--":
639
+ cleaned.extend(args[index + 1 :])
640
+ break
641
+ if token == "--python":
642
+ if index + 1 >= len(args) or args[index + 1].startswith("--"):
643
+ return _report(
644
+ {
645
+ "ok": False,
646
+ "installed": False,
647
+ "app": spec.app,
648
+ "reason": "python_flag_missing",
649
+ },
650
+ 2,
651
+ )
652
+ override = args[index + 1]
653
+ index += 2
654
+ continue
655
+ if token.startswith("--python="):
656
+ value = token.split("=", 1)[1]
657
+ if not value:
658
+ return _report(
659
+ {
660
+ "ok": False,
661
+ "installed": False,
662
+ "app": spec.app,
663
+ "reason": "python_flag_missing",
664
+ },
665
+ 2,
666
+ )
667
+ override = value
668
+ index += 1
669
+ continue
670
+ if token == "--install-python":
671
+ consent = True
672
+ index += 1
673
+ continue
674
+ cleaned.append(token)
675
+ index += 1
676
+
677
+ running = (
678
+ current if current is not None else (version_tuple(sys.version.split()[0]) or (0, 0, 0))
679
+ )
680
+ continuation = main_fn or spec.package_main
681
+
682
+ if override is not None:
683
+ found = probe(override)
684
+ if not found or found < spec.required:
685
+ return _report(
686
+ _too_old(format_version(found) if found else None, spec, override=True), 3
687
+ )
688
+ return _exec(exec_fn, Path(override), root, spec, cleaned)
689
+
690
+ if running >= spec.required:
691
+ if continuation is not None:
692
+ os.environ[spec.env_var] = sys.executable
693
+ return continuation(cleaned)
694
+ if _handed_off(spec):
695
+ return _report(_missing_continuation(spec), 7)
696
+ return _exec(exec_fn, Path(sys.executable), root, spec, cleaned)
697
+
698
+ python = venv_python(root, spec.venv_dir)
699
+ broken_venv: tuple[Path, Version] | None = None
700
+ if python.is_file():
701
+ found = probe(str(python))
702
+ if found and found >= spec.required:
703
+ if spec.verify_import is None:
704
+ return _exec(exec_fn, python, root, spec, cleaned)
705
+ if _installed(python, spec, root, dict(os.environ), run):
706
+ return _exec(exec_fn, python, root, spec, cleaned)
707
+ broken_venv = (python, found)
708
+
709
+ if not consent:
710
+ if broken_venv is not None:
711
+ return _report(_needs_install(broken_venv[0], spec, broken_venv[1]), 6)
712
+ return _report(
713
+ _too_old(
714
+ format_version(running),
715
+ spec,
716
+ needs_consent=True,
717
+ card=spec.card,
718
+ next=f"python {spec.entrypoint} --install-python",
719
+ ),
720
+ 3,
721
+ )
722
+
723
+ provisioned = provision(root, spec, uv=uv, run=run, probe=probe)
724
+ if not provisioned.get("ok"):
725
+ payload = dict(provisioned)
726
+ payload["installed"] = False
727
+ code = 4 if provisioned.get("reason") == "needs_uv" else 5
728
+ return _report(payload, code)
729
+ return _exec(exec_fn, Path(str(provisioned["interpreter"])), root, spec, cleaned)
File without changes
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-mcp-bootstrap
3
+ Version: 0.1.3
4
+ Summary: Pre-install Python runtime bootstrap for MCP applications.
5
+ Project-URL: Repository, https://github.com/4zhmv6zvj2-afk/python-mcp-bootstrap
6
+ Author-email: Ron <4zhmv6zvj2-afk@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: bootstrap,mcp,python,runtime,uv
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.14
18
+ Provides-Extra: dev
19
+ Requires-Dist: build>=1.2; extra == 'dev'
20
+ Requires-Dist: mypy>=1.13; extra == 'dev'
21
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
22
+ Requires-Dist: pytest>=8.3; extra == 'dev'
23
+ Requires-Dist: ruff>=0.9; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # python-mcp-bootstrap
27
+
28
+ A small setup helper for Python apps that use MCP.
29
+
30
+ MCP (Model Context Protocol) is a way for AI assistants to use tools and data.
31
+ Before one of those apps starts, this helper makes sure it runs on a supported
32
+ version of Python. If your computer does not have one, it explains what is
33
+ missing and asks before downloading anything. It never changes the Python that
34
+ is already installed.
35
+
36
+ This is a bootstrap *for* MCP applications, not an MCP implementation. It has
37
+ no runtime dependency on the MCP SDK and never imports one.
38
+
39
+ **Most people do not need to run this directly.** The app's setup script uses
40
+ it. If you are a developer adding it to an app, jump to
41
+ [For developers](#for-developers).
42
+
43
+
44
+ ## Do I need this?
45
+
46
+ - If an app's setup tells you to run `python first_run.py`, that script uses
47
+ this helper. Follow the app's instructions.
48
+ - If you are building or packaging an MCP app and need a first-run Python
49
+ check, use this library.
50
+ - If you just want to run an app that is already set up, no.
51
+
52
+ ## What it does
53
+
54
+ 1. Looks for Python 3.14 or newer.
55
+ 2. Looks for a working `.venv` in the project.
56
+ 3. If the Python or the app environment is missing or incomplete, prints a
57
+ JSON report with a `card` and a `next` command. Nothing is downloaded.
58
+ 4. If you agree, the wrapper re-runs with `--install-python`, which installs
59
+ what is missing inside the project.
60
+ 5. Continues setup with that Python.
61
+
62
+ It can also check a Python you name:
63
+
64
+ ```bash
65
+ python first_run.py --python /path/to/python
66
+ ```
67
+
68
+ ## What it will not do
69
+
70
+ - It will not download Python without `--install-python`.
71
+ - It will not change your system Python or system packages.
72
+ - It will not delete an existing `.venv`; a wrong one is moved aside.
73
+ - It will not run `curl | sh`. If `uv` is missing, it prints a tip with the
74
+ official uv installation page and stops. It never runs that tip.
75
+ - It will not run commands through a shell.
76
+ - It masks credential-shaped values in its reports and captured output.
77
+
78
+ ## For developers
79
+
80
+ This repository is a library, not the app. The command-line tool is a
81
+ diagnostic:
82
+
83
+ ```bash
84
+ python -m python_mcp_bootstrap --check
85
+ python -m python_mcp_bootstrap --version
86
+ ```
87
+
88
+ Distribution is GitHub Releases only: install the wheel or sdist from the
89
+ [releases page](https://github.com/4zhmv6zvj2-afk/python-mcp-bootstrap/releases).
90
+ This project is **not published on PyPI**, so `pip install python-mcp-bootstrap`
91
+ does not resolve.
92
+
93
+ Use `BootstrapSpec` and `bootstrap()` from an app's first-run wrapper:
94
+
95
+ ```python
96
+ import sys
97
+ from pathlib import Path
98
+
99
+ from python_mcp_bootstrap import BootstrapSpec, bootstrap
100
+
101
+
102
+ def continue_setup(args: list[str]) -> int:
103
+ # The rest of setup goes here.
104
+ del args
105
+ return 0
106
+
107
+
108
+ root = Path(__file__).resolve().parent
109
+ spec = BootstrapSpec(
110
+ app="example-mcp",
111
+ entrypoint="first_run.py",
112
+ env_var="EXAMPLE_MCP_INTERPRETER",
113
+ install_args=("-e", "."),
114
+ verify_import="example_mcp",
115
+ package_main=continue_setup,
116
+ )
117
+ raise SystemExit(bootstrap(sys.argv[1:], root, spec))
118
+ ```
119
+
120
+ `package_main` is the function `bootstrap()` calls when the current Python is
121
+ already suitable. Pass it: an entry point that calls `bootstrap()`
122
+ unconditionally needs somewhere to continue.
123
+
124
+ The first pass may hand off once, and it records the chosen interpreter in
125
+ `spec.env_var`. A later pass that reaches `bootstrap()` with that variable
126
+ already set and no continuation gets a `missing_continuation` report (exit
127
+ `7`) instead of another exec, so the re-exec loop is gone. A wrapper that
128
+ branches on `spec.env_var` and skips `bootstrap()` after the handoff keeps
129
+ working, which is what the variable is for.
130
+
131
+ `bootstrap()` checks `--python`, the current interpreter, and an existing
132
+ `.venv`, in that order. It only offers to install the pinned Python after
133
+ `--install-python`. An existing `.venv` is also checked with `verify_import`;
134
+ the check runs isolated (`-I -P`), so only that venv's installed packages
135
+ count. If the app or MCP module is missing, the repair card asks for
136
+ `--install-python` before running the installer.
137
+
138
+ `install_args` are consumer-controlled. Path-like entries are rejected when
139
+ the spec is built if they are absolute, Windows-rooted, or contain `..`,
140
+ including attached short-option values (`-t/tmp/x`). Venv-redirecting flags
141
+ (`--target`, `--prefix`, `--root`, `--python` and their short forms) are
142
+ rejected. Non-path flags such as `--index-url` pass through; credentials in
143
+ reports are masked.
144
+
145
+ The core is one stdlib-only file, `core.py`. It must stay importable on Python
146
+ 3.12 because it runs before the app's dependencies are installed; CI runs the
147
+ suite on 3.12 as well as on the supported 3.14 and 3.15 interpreters.
148
+
149
+ ### Vendoring
150
+
151
+ Consumers copy `core.py` and a `VENDOR.json` manifest instead of taking a
152
+ runtime dependency:
153
+
154
+ ```bash
155
+ python scripts/vendor.py \
156
+ --target /path/to/consumer \
157
+ --dest src/example/_vendor/python_mcp_bootstrap.py
158
+ ```
159
+
160
+ Use `--check` to verify a pinned copy. The example app vendors `core.py` into
161
+ `examples/_vendor/` and CI runs the same `vendor.py --check` sequence.
162
+
163
+ - Contract: [docs/contract.md](docs/contract.md)
164
+ - Integration details: [docs/integration.md](docs/integration.md)
165
+ - Example first run: [examples/first_run.py](examples/first_run.py)
166
+ - Changelog: [CHANGELOG.md](CHANGELOG.md)
167
+ - Contributing and release checks: [CONTRIBUTING.md](CONTRIBUTING.md)
168
+
169
+ ## Development
170
+
171
+ Requires Python 3.14+. The development pin is 3.15.2.
172
+
173
+ ```bash
174
+ python3 -m venv .venv
175
+ . .venv/bin/activate
176
+ python -m pip install -e '.[dev]'
177
+ python -m pytest --cov=python_mcp_bootstrap --cov-fail-under=90
178
+ python -m ruff format --check
179
+ python -m ruff check
180
+ python -m mypy
181
+ python -m build
182
+ ```
183
+
184
+ CI targets Python 3.14.10 and 3.15.2 on Ubuntu, macOS, and Windows, plus a 3.12
185
+ compatibility job, a wheel/release gate, and the vendored example first run.
186
+ CI is advisory for now (no required status checks); check the current matrix
187
+ before merging, and treat a pending cell as not green.
188
+
189
+ ## Who maintains this
190
+
191
+ Maintained by Ron as a small single-maintainer project. It exists because
192
+ first-run setup for MCP apps kept re-solving the same problem: make sure a
193
+ supported interpreter is present before the app's dependencies are, without
194
+ touching the system Python.
195
+
196
+ Issues and pull requests are welcome. There is no support SLA. `SECURITY.md`
197
+ lists what the bootstrap does and does not guarantee.
198
+
199
+ ## License
200
+
201
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ python_mcp_bootstrap/__init__.py,sha256=IS3hCfRI150bXc3zj5TwhGRiKkoLiybM6kdz6SExNhE,923
2
+ python_mcp_bootstrap/__main__.py,sha256=S-r7PMFWeNsD5VqQP6zt-C2BjLgs7iPjpNf3iJ3qRQw,164
3
+ python_mcp_bootstrap/cli.py,sha256=oiwp4bwA8xWy0e5dhyjNQJtJrdHvqoRUb9OFKKF_Yn8,1413
4
+ python_mcp_bootstrap/core.py,sha256=2HLJFazzbnWXY62C3-JCga_2fFFqBiyqcZPXHE4-qUQ,25265
5
+ python_mcp_bootstrap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ python_mcp_bootstrap-0.1.3.dist-info/METADATA,sha256=gEYKUZwUCUTS9Rxaa5rsvjsjd0Nt4y1b4rTLMFZDUs4,7399
7
+ python_mcp_bootstrap-0.1.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ python_mcp_bootstrap-0.1.3.dist-info/entry_points.txt,sha256=YUL-12FHuCP29sNLNxkUFayI6-WytswCrpMbJBuvrYw,71
9
+ python_mcp_bootstrap-0.1.3.dist-info/licenses/LICENSE,sha256=r4oNhys29PlJU-UAqHfmy9abFKKc0e5S17yGK0cdik0,1090
10
+ python_mcp_bootstrap-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ python-mcp-bootstrap = python_mcp_bootstrap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 python-mcp-bootstrap contributors
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.