diff --git a/docs/MCP_DEPENDENCIES.md b/docs/MCP_DEPENDENCIES.md new file mode 100644 index 00000000..396398e6 --- /dev/null +++ b/docs/MCP_DEPENDENCIES.md @@ -0,0 +1,35 @@ +# Updating MCP dependencies + +The IaC, Pricing, Serverless and Transform launch definitions use exact server +versions and a fixed `uvx --exclude-newer` timestamp. `@latest` forces cache +refreshes at every launch. An exact server version alone still allows its ranged +dependencies to change when a refresh or cache rebuild resolves them again. +The timestamp excludes later releases of **all** dependencies, keeping normal +repeated launches on the same resolved package set and cached environment. + +This is a dependency release cutoff, not a cross-platform lockfile. Different +Python versions, platforms or indexes can resolve different packages. Yanked or +removed distributions and explicit user resolver overrides can also affect a +fresh resolution. The fix does not clean previously accumulated environments. +See [uv's tool environment documentation](https://docs.astral.sh/uv/concepts/tools/) +and [the resolver options](https://docs.astral.sh/uv/reference/cli/#uv-tool-run). + +To update these servers: + +1. Choose published server versions and a UTC cutoff after their release dates. +2. Update the versions and cutoff together in the affected `.mcp.json` files. + Keep both IaC launch definitions identical. +3. Run `mise run test:python`. The local-index regression publishes a new ranged + dependency, demonstrates drift with only the top-level pin, and verifies that + the cutoff preserves versions and the same environment even on refresh. +4. Run `mise run test:mcp-launches` for real-package startup and cache-reuse checks. + This downloads packages into an isolated temporary cache, initializes MCP and + lists tools without invoking AWS operations. It needs network access to PyPI. + Transform needs a writable `~/.aws-transform-mcp` log directory; its test skips + accounts with an existing persisted Transform session to avoid using it. +5. Run `mise run build` and review dependency/security changes before publishing. + +Advance the cutoff when reviewing dependency updates, including security fixes; +ordinary launches intentionally do not adopt newer releases automatically. +Proxy launch definitions are maintained separately in +[PR #274](https://github.com/awslabs/agent-plugins/pull/274). diff --git a/mise.toml b/mise.toml index 3344385f..25837d8e 100644 --- a/mise.toml +++ b/mise.toml @@ -121,6 +121,11 @@ run = "uv run --with pytest pytest tools plugins || [ $? -eq 5 ]" description = "Run Node (JS/TS) unit tests when a package.json defines them" run = "if [ -f package.json ] && grep -q '\"test\"' package.json; then npm test; else echo 'no package.json test script; skipping'; fi" +[tasks."test:mcp-launches"] +description = "Check real MCP startup and environment reuse (downloads packages; no AWS operations)" +env = { MCP_LAUNCH_INTEGRATION = "1" } +run = "uv run --with pytest pytest tools/test_mcp_environments.py -k real_mcp -v -s" + [tasks.test] description = "Run unit tests across languages" run = [ diff --git a/plugins/aws-serverless/.mcp.json b/plugins/aws-serverless/.mcp.json index 051fac84..fd4d080e 100644 --- a/plugins/aws-serverless/.mcp.json +++ b/plugins/aws-serverless/.mcp.json @@ -1,11 +1,13 @@ { "mcpServers": { "aws-serverless-mcp": { - "command": "uvx", "args": [ - "awslabs.aws-serverless-mcp-server@latest", + "--exclude-newer", + "2026-10-18T00:00:00Z", + "awslabs.aws-serverless-mcp-server==0.2.0", "--allow-write" ], + "command": "uvx", "env": { "FASTMCP_LOG_LEVEL": "ERROR" }, diff --git a/plugins/aws-transform/.mcp.json b/plugins/aws-transform/.mcp.json index 6894cdac..d0b955b9 100644 --- a/plugins/aws-transform/.mcp.json +++ b/plugins/aws-transform/.mcp.json @@ -1,10 +1,12 @@ { "mcpServers": { "aws-transform-mcp": { - "command": "uvx", "args": [ - "awslabs.aws-transform-mcp-server@latest" - ] + "--exclude-newer", + "2026-10-18T00:00:00Z", + "awslabs.aws-transform-mcp-server==0.2.3" + ], + "command": "uvx" } } } diff --git a/plugins/codebase-documentor-for-aws/.mcp.json b/plugins/codebase-documentor-for-aws/.mcp.json index a1c664a3..90344993 100644 --- a/plugins/codebase-documentor-for-aws/.mcp.json +++ b/plugins/codebase-documentor-for-aws/.mcp.json @@ -1,14 +1,16 @@ { "mcpServers": { - "awsknowledge": { - "type": "http", - "url": "https://knowledge-mcp.global.api.aws" - }, "awsiac": { "args": [ - "awslabs.aws-iac-mcp-server@latest" + "--exclude-newer", + "2026-10-18T00:00:00Z", + "awslabs.aws-iac-mcp-server==1.0.26" ], "command": "uvx" + }, + "awsknowledge": { + "type": "http", + "url": "https://knowledge-mcp.global.api.aws" } } } diff --git a/plugins/deploy-on-aws/.mcp.json b/plugins/deploy-on-aws/.mcp.json index 0e201a27..ee4ed977 100644 --- a/plugins/deploy-on-aws/.mcp.json +++ b/plugins/deploy-on-aws/.mcp.json @@ -2,7 +2,9 @@ "mcpServers": { "awsiac": { "args": [ - "awslabs.aws-iac-mcp-server@latest" + "--exclude-newer", + "2026-10-18T00:00:00Z", + "awslabs.aws-iac-mcp-server==1.0.26" ], "command": "uvx" }, @@ -12,7 +14,9 @@ }, "awspricing": { "args": [ - "awslabs.aws-pricing-mcp-server@latest" + "--exclude-newer", + "2026-10-18T00:00:00Z", + "awslabs.aws-pricing-mcp-server==1.1.1" ], "command": "uvx", "disabled": false, diff --git a/tools/test_mcp_environments.py b/tools/test_mcp_environments.py new file mode 100644 index 00000000..d337d447 --- /dev/null +++ b/tools/test_mcp_environments.py @@ -0,0 +1,505 @@ +"""Exercise uv's real resolver and cache against a changing local package index.""" + +import asyncio +import hashlib +import io +import json +import os +import shutil +import subprocess # nosec B404: tests execute uv with argv, never a shell +import sys +import tempfile +import threading +import zipfile +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +LAUNCHES = [ + ("deploy-on-aws", "awsiac"), + ("deploy-on-aws", "awspricing"), + ("codebase-documentor-for-aws", "awsiac"), + ("aws-serverless", "aws-serverless-mcp"), + ("aws-transform", "aws-transform-mcp"), +] + + +def clean_environment(): + keys = ( + "PATH", + "SYSTEMROOT", + "TEMP", + "TMP", + "TMPDIR", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + ) + return {key: os.environ[key] for key in keys if key in os.environ} + + +def server_config(plugin, server): + return json.loads((ROOT / "plugins" / plugin / ".mcp.json").read_text())[ + "mcpServers" + ][server] + + +def wheel(name, version, dependency=None): + """Build inert fixtures without a build backend or external dependencies.""" + normalized = name.replace("-", "_").replace(".", "_") + info = f"{normalized}-{version}.dist-info" + content = io.BytesIO() + with zipfile.ZipFile(content, "w") as archive: + metadata = f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + if dependency: + metadata += f"Requires-Dist: {dependency}\n" + archive.writestr(f"{info}/METADATA", metadata) + archive.writestr( + f"{info}/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + if dependency: + archive.writestr( + f"{info}/entry_points.txt", + f"[console_scripts]\n{name} = cache_probe:main\n", + ) + archive.writestr( + "cache_probe.py", + "import importlib.metadata as m, json, os, sys\n" + "def main():\n" + " print(json.dumps({'prefix': sys.prefix,\n" + " 'versions': sorted((d.metadata['Name'], d.version) " + "for d in m.distributions()),\n" + " 'args': sys.argv[1:],\n" + " 'profile': os.environ.get('AWS_PROFILE'),\n" + " 'region': os.environ.get('AWS_REGION')}))\n", + ) + # RECORD is required for a valid wheel; uv computes the installed files. + archive.writestr(f"{info}/RECORD", "") + return f"{normalized}-{version}-py3-none-any.whl", content.getvalue() + + +@pytest.fixture +def package_index(): + packages, files = {}, {} + + class Handler(BaseHTTPRequestHandler): + def do_HEAD(self): + self.do_GET(head_only=True) + + def do_GET(self, head_only=False): + if self.path.startswith("/files/"): + body = files[self.path.removeprefix("/files/")] + content_type = "application/octet-stream" + else: + name = self.path.strip("/").split("/")[-1] + body = json.dumps( + { + "meta": {"api-version": "1.1"}, + "name": name, + "files": packages.get(name, []), + } + ).encode() + content_type = "application/vnd.pypi.simple.v1+json" + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if not head_only: + self.wfile.write(body) + + def log_message(self, *_args): + pass + + http = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=http.serve_forever, daemon=True) + thread.start() + + def publish(name, version, uploaded, dependency=None): + filename, data = wheel(name, version, dependency) + files[filename] = data + normalized = name.replace("_", "-").replace(".", "-").lower() + packages.setdefault(normalized, []).append( + { + "filename": filename, + "url": f"/files/{filename}", + "hashes": {"sha256": hashlib.sha256(data).hexdigest()}, + "size": len(data), + "upload-time": uploaded.isoformat(), + "requires-python": ">=3.10", + "yanked": False, + } + ) + + try: + yield f"http://127.0.0.1:{http.server_port}/simple/", publish + finally: + http.shutdown() + thread.join(timeout=5) + http.server_close() + + +@pytest.mark.parametrize("plugin,server", LAUNCHES) +def test_launch_reuses_environment_after_transitive_release( + plugin, server, tmp_path, package_index +): + config = server_config(plugin, server) + args = config["args"] + assert config["command"] == "uvx" # nosec B101: pytest assertion + assert args[0] == "--exclude-newer" # nosec B101: pytest assertion + cutoff = datetime.fromisoformat(args[1].replace("Z", "+00:00")) + assert cutoff.tzinfo == timezone.utc # nosec B101: pytest assertion + name, version = args[2].split("==") + assert "@latest" not in " ".join(args) # nosec B101: pytest assertion + index, publish = package_index + publish("cache-dependency", "1.0.0", cutoff - timedelta(days=1)) + publish(name, version, cutoff - timedelta(days=1), "cache-dependency>=1") + + # Keep the test independent of caller credentials, indexes and uv settings. + env = clean_environment() + env.update(config.get("env", {})) + env.update( + UV_CACHE_DIR=str(tmp_path / "cache"), + UV_TOOL_DIR=str(tmp_path / "tools"), + UV_PYTHON_DOWNLOADS="never", + AWS_PROFILE="fixture-profile", + AWS_REGION="us-east-1", + NO_PROXY="127.0.0.1,localhost", + ) + uv = shutil.which("uv") + assert uv, "uv is required; run this test through mise run test:python" # nosec B101: pytest assertion + + def launch(arguments, refresh=False): + command = [ + uv, + "tool", + "run", + "--no-config", + "--python", + sys.executable, + "--default-index", + index, + ] + if refresh: + command.append("--refresh") + result = subprocess.run( # nosec B603: uv argv from repository fixtures + [*command, *arguments], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr # nosec B101: pytest assertion + return json.loads(result.stdout) + + first = launch(args) + marker = Path(first["prefix"]) / "pyvenv.cfg" + identity = (marker.stat().st_ino, marker.stat().st_mtime_ns) + assert launch(args) == first # nosec B101: pytest assertion + + # Control: even an exact top-level pin can drift after a dependency release. + unbounded = launch(args[2:], refresh=True) + publish("cache-dependency", "2.0.0", cutoff + timedelta(days=1)) + drifted = launch(args[2:], refresh=True) + assert unbounded["versions"] != drifted["versions"] # nosec B101: pytest assertion + assert unbounded["prefix"] != drifted["prefix"] # nosec B101: pytest assertion + + # Refresh forces resolution against the changed index rather than letting + # a warm cache hide transitive drift. The cutoff still selects the old set. + assert launch(args, refresh=True) == first # nosec B101: pytest assertion + assert launch(args) == first # nosec B101: pytest assertion + assert (marker.stat().st_ino, marker.stat().st_mtime_ns) == identity # nosec B101: pytest assertion + assert first["args"] == args[3:] # nosec B101: pytest assertion + assert first["profile"] == "fixture-profile" # nosec B101: pytest assertion + assert first["region"] == "us-east-1" # nosec B101: pytest assertion + assert ["cache-dependency", "1.0.0"] in first["versions"] # nosec B101: pytest assertion + + +def test_serverless_keeps_write_access_and_iac_requests_match(): + assert server_config("aws-serverless", "aws-serverless-mcp")["args"][3:] == [ # nosec B101: pytest assertion + "--allow-write" + ] + assert server_config("deploy-on-aws", "awsiac") == server_config( # nosec B101: pytest assertion + "codebase-documentor-for-aws", "awsiac" + ) + + +@pytest.fixture(scope="module") +def real_cache(tmp_path_factory): + return tmp_path_factory.mktemp("mcp-real") + + +async def mcp_catalog(command, env): + """Initialize and enumerate tools, never execute a tool or AWS operation.""" + with tempfile.TemporaryFile() as errors: + process = await asyncio.create_subprocess_exec( + *command, + env=env, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=errors, + ) + + async def send(message): + process.stdin.write((json.dumps(message) + "\n").encode()) + await process.stdin.drain() + + async def receive(request_id): + while True: + line = await asyncio.wait_for(process.stdout.readline(), timeout=60) + if not line: + errors.seek(0) + raise AssertionError(errors.read().decode(errors="replace")) + message = json.loads(line) + if message.get("id") == request_id: + assert "error" not in message, message # nosec B101: pytest assertion + return message["result"] + + try: + await send( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "cache-regression", "version": "1.0"}, + }, + } + ) + initialized = await receive(1) + await send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + tools, cursor, request_id = [], None, 2 + while True: + await send( + { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/list", + "params": {"cursor": cursor} if cursor else {}, + } + ) + page = await receive(request_id) + tools.extend(page["tools"]) + cursor = page.get("nextCursor") + if not cursor: + break + request_id += 1 + return initialized["serverInfo"], sorted( + tools, key=lambda tool: tool["name"] + ) + finally: + if process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=10) + except TimeoutError: + process.kill() + await process.wait() + + +@pytest.mark.skipif( + os.environ.get("MCP_LAUNCH_INTEGRATION") != "1", + reason="opt-in package downloads: mise run test:mcp-launches", +) +@pytest.mark.parametrize("plugin,server", LAUNCHES) +def test_real_mcp_repeated_launches(plugin, server, real_cache): + home = Path.home() + if ( + plugin == "aws-transform" + and (home / ".aws-transform-mcp" / "config.json").exists() + ): + pytest.skip( + "Run in a clean account: Transform reads its persisted session at startup" + ) + config = server_config(plugin, server) + args = config["args"] + uv = shutil.which("uv") + assert uv # nosec B101: pytest assertion + env = clean_environment() + env.update(config.get("env", {})) + env.update( + # The guard and child must use the same home, including caller overrides. + HOME=str(home), + USERPROFILE=str(home), + UV_CACHE_DIR=str(real_cache / "cache"), + UV_TOOL_DIR=str(real_cache / "tools"), + UV_PYTHON_DOWNLOADS="never", + AWS_CONFIG_FILE=os.devnull, + AWS_SHARED_CREDENTIALS_FILE=os.devnull, + AWS_EC2_METADATA_DISABLED="true", + AWS_DEFAULT_REGION="us-east-1", + AWS_REGION="us-east-1", + ) + # Introspect the same resolved requirement without starting its server. + probe = [ + uv, + "tool", + "run", + "--no-config", + "--python", + sys.executable, + *args[:2], + "--from", + args[2], + "python", + "-c", + "import importlib.metadata as m, json, sys; " + "print(json.dumps({'prefix':sys.prefix, 'versions':" + "sorted((d.metadata['Name'],d.version) for d in m.distributions())}))", + ] + + def snapshot(refresh=False): + command = [*probe[:3], "--refresh", *probe[3:]] if refresh else probe + result = subprocess.run( # nosec B603: uv argv from repository fixtures + command, env=env, capture_output=True, text=True, timeout=300 + ) + assert result.returncode == 0, result.stderr # nosec B101: pytest assertion + return json.loads(result.stdout) + + first = snapshot() + prefix = Path(first["prefix"]) + marker = prefix / "pyvenv.cfg" + identity = (marker.stat().st_ino, marker.stat().st_mtime_ns) + command = [uv, "tool", "run", "--no-config", "--python", sys.executable, *args] + # Packages are cached now. Prevent outbound HTTP from server startup and + # avoid using local AWS credentials; listing tools needs neither. + server_env = dict( + env, + UV_OFFLINE="true", + HTTP_PROXY="http://127.0.0.1:9", + HTTPS_PROXY="http://127.0.0.1:9", + ALL_PROXY="http://127.0.0.1:9", + ) + catalog = asyncio.run(mcp_catalog(command, server_env)) + assert catalog[1], "Server advertised no tools" # nosec B101: pytest assertion + environments = set((real_cache / "cache").rglob("pyvenv.cfg")) + for refresh in (False, True): + assert snapshot(refresh=refresh) == first # nosec B101: pytest assertion + assert asyncio.run(mcp_catalog(command, server_env)) == catalog # nosec B101: pytest assertion + assert set((real_cache / "cache").rglob("pyvenv.cfg")) == environments # nosec B101: pytest assertion + assert (marker.stat().st_ino, marker.stat().st_mtime_ns) == identity # nosec B101: pytest assertion + print( + f"{plugin}/{server}: {len(first['versions'])} packages, " + f"{len(catalog[1])} tools, stable identity/versions/catalog over 3 launches" + ) + + +@pytest.mark.parametrize("home_override", [True, False], ids=["override", "fallback"]) +def test_real_launch_home_matches_guard(tmp_path, monkeypatch, home_override): + checked_home = tmp_path / "checked-home" + checked_home.mkdir() + unchecked_home = tmp_path / "unchecked-home" + session = unchecked_home / ".aws-transform-mcp" / "config.json" + session.parent.mkdir(parents=True) + session.write_text("{}") + if home_override: + monkeypatch.setenv("HOME", str(checked_home)) + monkeypatch.setenv("USERPROFILE", str(checked_home)) + else: + for key in ("HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"): + monkeypatch.delenv(key, raising=False) + # Model an account home without consulting the real account database. + monkeypatch.setattr(Path, "home", classmethod(lambda cls: checked_home)) + credential_keys = ( + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + ) + for key in credential_keys: + monkeypatch.setenv(key, "synthetic-test-value") + + marker = tmp_path / "cache" / "fixture" / "pyvenv.cfg" + marker.parent.mkdir(parents=True) + marker.write_text("fixture") + environments, homes = [], [] + run = subprocess.run + + def snapshot(command, *, env, **kwargs): + environments.append(env) + return subprocess.CompletedProcess( + command, 0, json.dumps({"prefix": str(marker.parent), "versions": []}) + ) + + async def catalog(command, env): + environments.append(env) + # Only this inert Python probe is executed, never uv or an MCP server. + # Both account fallbacks point to fixtures, even before the fix. + result = run( # nosec B603: inert Python argv with synthetic home paths + [ + sys.executable, + "-I", + "-S", + "-c", + "import json, ntpath, os, sys\n" + "from pathlib import Path\n" + "from types import SimpleNamespace\n" + "if os.name == 'posix':\n" + " import pwd\n" + " pwd.getpwuid = lambda uid: SimpleNamespace(pw_dir=sys.argv[1])\n" + "os.environ.setdefault('HOMEDRIVE', os.path.splitdrive(sys.argv[1])[0])\n" + "os.environ.setdefault('HOMEPATH', os.path.splitdrive(sys.argv[1])[1])\n" + "home = Path.home()\n" + "print(json.dumps({'home': str(home), 'windows_home': ntpath.expanduser('~'),\n" + " 'has_session': (home / '.aws-transform-mcp' / 'config.json').exists()}))\n", + str(unchecked_home), + ], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr # nosec B101: pytest assertion + homes.append(json.loads(result.stdout)) + return {"name": "fixture"}, [{"name": "fixture-tool"}] + + monkeypatch.setattr(shutil, "which", lambda name: "fixture-uv") + monkeypatch.setattr(subprocess, "run", snapshot) + monkeypatch.setattr(sys.modules[__name__], "mcp_catalog", catalog) + test_real_mcp_repeated_launches("aws-transform", "aws-transform-mcp", tmp_path) + + assert ( + homes + == [ + { # nosec B101: pytest assertion + "home": str(checked_home), + "windows_home": str(checked_home), + "has_session": False, + } + ] + * 3 + ) + assert len(environments) == 6 # nosec B101: pytest assertion + for env in environments: + assert env["HOME"] == env["USERPROFILE"] == str(checked_home) # nosec B101: pytest assertion + assert all(key not in env for key in credential_keys) # nosec B101: pytest assertion + assert ( + env["AWS_CONFIG_FILE"] == env["AWS_SHARED_CREDENTIALS_FILE"] == os.devnull + ) # nosec B101: pytest assertion + assert env["AWS_EC2_METADATA_DISABLED"] == "true" # nosec B101: pytest assertion + + +def test_real_launch_skips_persisted_transform_session(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + session = tmp_path / ".aws-transform-mcp" / "config.json" + session.parent.mkdir() + session.write_text("{}") + + def unexpected_launch(*args, **kwargs): + pytest.fail("Persisted Transform state must prevent any probe or server launch") + + monkeypatch.setattr(subprocess, "run", unexpected_launch) + monkeypatch.setattr(sys.modules[__name__], "mcp_catalog", unexpected_launch) + with pytest.raises( + pytest.skip.Exception, match="Transform reads its persisted session" + ): + test_real_mcp_repeated_launches("aws-transform", "aws-transform-mcp", tmp_path)