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.
- python_mcp_bootstrap/__init__.py +58 -0
- python_mcp_bootstrap/__main__.py +8 -0
- python_mcp_bootstrap/cli.py +57 -0
- python_mcp_bootstrap/core.py +729 -0
- python_mcp_bootstrap/py.typed +0 -0
- python_mcp_bootstrap-0.1.3.dist-info/METADATA +201 -0
- python_mcp_bootstrap-0.1.3.dist-info/RECORD +10 -0
- python_mcp_bootstrap-0.1.3.dist-info/WHEEL +4 -0
- python_mcp_bootstrap-0.1.3.dist-info/entry_points.txt +2 -0
- python_mcp_bootstrap-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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.
|