diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 716984aca..259eadf59 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -251,6 +251,7 @@ mcp-agent deploy [OPTIONS] | `--dry-run` | Validate without deploying | `false` | | `--api-url` | API base URL | `https://deployments.mcp-agent.com` | | `--api-key` | API key | From env or config | +| `--ignore-file ` | Use a specific ignore file (gitignore syntax). Defaults to `.mcpacignore` if present. | None | **Examples:** ```bash @@ -264,8 +265,16 @@ mcp-agent deploy my-agent \ # Dry run mcp-agent deploy my-agent --dry-run + +# Use a custom ignore file +mcp-agent deploy my-agent --ignore-file .deployignore ``` +Ignore file usage + +- Detection order: 1) `--ignore-file ` (highest precedence), 2) `.mcpacignore` in `--config-dir`, 3) `.mcpacignore` in the working directory (CWD). +- Patterns follow gitignore syntax and are evaluated relative to `--config-dir`. + ### mcp-agent cloud configure Configure access to a deployed MCP app. @@ -489,4 +498,4 @@ mcp-agent deploy my-agent --dry-run - [Configuration Guide](/configuration) - [Cloud Deployment](/cloud/getting-started) - [Workflow Patterns](/workflows/overview) -- [Examples](https://github.com/lastmile-ai/mcp-agent/tree/main/examples) \ No newline at end of file +- [Examples](https://github.com/lastmile-ai/mcp-agent/tree/main/examples) diff --git a/docs/cloud/overview.mdx b/docs/cloud/overview.mdx index 8c31c3b1a..fb2fd10ab 100644 --- a/docs/cloud/overview.mdx +++ b/docs/cloud/overview.mdx @@ -24,6 +24,8 @@ description: "[In beta] Deploy and manage AI agents as MCP servers" When packaging your mcp-agent app for the cloud, our CLI will be searching for `main.py`. The entire directory will be deployed, so you can reference other files and upload other assets. +You can optionally exclude files from the bundle using an ignore file (gitignore syntax). Precedence: 1) `--ignore-file ` (explicit override), 2) `.mcpacignore` in `--config-dir`, 3) `.mcpacignore` in the working directory (CWD). + 2. Make sure you have either a `pyproject.toml` or a `requirements.txt` file in your app directory. 3. Mark your functions that you'd like to be tool calls with the `@app.tool` decorators diff --git a/pyproject.toml b/pyproject.toml index 7be2a2d7b..c9fe1c6f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ crewai = [ cli = [ "hvac>=1.1.1", "httpx>=0.28.1", + "pathspec>=0.12.1", "pyjwt>=2.10.1", "typer[all]>=0.15.3", "watchdog>=6.0.0" diff --git a/src/mcp_agent/cli/cloud/commands/deploy/bundle_utils.py b/src/mcp_agent/cli/cloud/commands/deploy/bundle_utils.py new file mode 100644 index 000000000..b69e71170 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/deploy/bundle_utils.py @@ -0,0 +1,80 @@ +"""Ignore-file helpers for the deploy bundler. + +This module focuses on two things: +- Parse an ignore file (gitignore-compatible syntax) into a `PathSpec` matcher. +- Provide an adapter that works with `shutil.copytree(ignore=...)` to decide + which directory entries to skip during a copy. + +There is no implicit reading of `.gitignore` here. Callers must explicitly +pass the ignore file path they want to use (e.g., `.mcpacignore`). +""" + +from pathlib import Path +from typing import Optional, Set +import pathspec + + +def create_pathspec_from_gitignore(ignore_file_path: Path) -> Optional[pathspec.PathSpec]: + """Create and return a `PathSpec` from an ignore file. + + The file is parsed using the `gitwildmatch` (gitignore) syntax. If the file + does not exist, `None` is returned so callers can fall back to default + behavior. + + Args: + ignore_file_path: Path to the ignore file (e.g., `.mcpacignore`). + + Returns: + A `PathSpec` that can match file/directory paths, or `None`. + """ + if not ignore_file_path.exists(): + return None + + with open(ignore_file_path, "r", encoding="utf-8") as f: + spec = pathspec.PathSpec.from_lines("gitwildmatch", f) + + return spec + + + +def should_ignore_by_gitignore( + path_str: str, names: list, project_dir: Path, spec: Optional[pathspec.PathSpec] +) -> Set[str]: + """Return the subset of `names` to ignore for `shutil.copytree`. + + This function is designed to be passed as the `ignore` callback to + `shutil.copytree`. For each entry in the current directory (`path_str`), it + computes the path relative to the `project_dir` root and checks it against + the provided `spec` (a `PathSpec` created from an ignore file). + + Notes: + - If `spec` is `None`, this returns an empty set (no additional ignores). + - For directories, we also check the relative path with a trailing slash + (a common gitignore convention). + """ + if spec is None: + return set() + + ignored: Set[str] = set() + current_path = Path(path_str) + + for name in names: + full_path = current_path / name + try: + rel_path = full_path.relative_to(project_dir) + except ValueError: + # If `full_path` is not under `project_dir`, ignore matching is skipped. + continue + + # Normalize to POSIX separators so patterns work cross-platform (Windows too) + rel_path_str = rel_path.as_posix() + + # Match files exactly; for directories also try with a trailing slash + # to respect patterns like `build/`. + if spec.match_file(rel_path_str): + ignored.add(name) + elif full_path.is_dir() and spec.match_file(rel_path_str + "/"): + ignored.add(name) + + + return ignored diff --git a/src/mcp_agent/cli/cloud/commands/deploy/main.py b/src/mcp_agent/cli/cloud/commands/deploy/main.py index 3bb2260c8..261c61385 100644 --- a/src/mcp_agent/cli/cloud/commands/deploy/main.py +++ b/src/mcp_agent/cli/cloud/commands/deploy/main.py @@ -116,6 +116,19 @@ def deploy_config( min=1, max=10, ), + ignore_file: Optional[Path] = typer.Option( + None, + "--ignore-file", + help=( + "Path to ignore file (gitignore syntax). Precedence: 1) --ignore-file , " + "2) .mcpacignore in --config-dir, 3) .mcpacignore in working directory." + ), + exists=False, + readable=True, + dir_okay=False, + file_okay=True, + resolve_path=True, + ), ) -> str: """Deploy an MCP agent using the specified configuration. @@ -324,6 +337,16 @@ def deploy_config( else: print_info("Skipping git tag (not a git repository)") + # Determine effective ignore path + ignore_path: Optional[Path] = None + if ignore_file is not None: + ignore_path = ignore_file + else: + candidate = config_dir / ".mcpacignore" + if not candidate.exists(): + candidate = Path.cwd() / ".mcpacignore" + ignore_path = candidate if candidate.exists() else None + app = run_async( _deploy_with_retry( app_id=app_id, @@ -331,6 +354,7 @@ def deploy_config( project_dir=config_dir, mcp_app_client=mcp_app_client, retry_count=retry_count, + ignore=ignore_path, ) ) @@ -359,6 +383,7 @@ async def _deploy_with_retry( project_dir: Path, mcp_app_client: MCPAppClient, retry_count: int, + ignore: Optional[Path], ): """Execute the deployment operations with retry logic. @@ -378,6 +403,7 @@ async def _deploy_with_retry( app_id=app_id, api_key=api_key, project_dir=project_dir, + ignore_file=ignore, ) except Exception as e: raise CLIError(f"Bundling failed: {str(e)}") from e diff --git a/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py b/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py index 6675eab4e..03a55fbea 100644 --- a/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py +++ b/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py @@ -18,6 +18,10 @@ utc_iso_now, ) +from .bundle_utils import ( + create_pathspec_from_gitignore, + should_ignore_by_gitignore, +) from .constants import ( CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_EMAIL, @@ -107,7 +111,9 @@ def _handle_wrangler_error(e: subprocess.CalledProcessError) -> None: print_error(clean_output) -def wrangler_deploy(app_id: str, api_key: str, project_dir: Path) -> None: +def wrangler_deploy( + app_id: str, api_key: str, project_dir: Path, ignore_file: Path | None = None +) -> None: """Bundle the MCP Agent using Wrangler. A thin wrapper around the Wrangler CLI to bundle the MCP Agent application code @@ -128,6 +134,7 @@ def wrangler_deploy(app_id: str, api_key: str, project_dir: Path) -> None: app_id (str): The application ID. api_key (str): User MCP Agent Cloud API key. project_dir (Path): The directory of the project to deploy. + ignore_file (Path | None): Optional path to a gitignore-style file for excluding files from the bundle. """ # Copy existing env to avoid overwriting @@ -165,18 +172,41 @@ def wrangler_deploy(app_id: str, api_key: str, project_dir: Path) -> None: with tempfile.TemporaryDirectory(prefix="mcp-deploy-") as temp_dir_str: temp_project_dir = Path(temp_dir_str) / "project" - # Copy the entire project to temp directory, excluding unwanted directories and secrets file - def ignore_patterns(_path, names): + # Load ignore rules (gitignore syntax) only if an explicit ignore file is provided + ignore_spec = ( + create_pathspec_from_gitignore(ignore_file) if ignore_file else None + ) + if ignore_file: + if ignore_spec is None: + print_warning( + f"Ignore file '{ignore_file}' not found; applying default excludes only" + ) + else: + print_info(f"Using ignore patterns from {ignore_file}") + else: + print_info("No ignore file provided; applying default excludes only") + + # Copy the entire project to temp directory, excluding unwanted directories and the live secrets file + def ignore_patterns(path_str, names): ignored = set() + + # Keep existing hardcoded exclusions (highest priority) for name in names: if (name.startswith(".") and name not in {".env"}) or name in { "logs", "__pycache__", "node_modules", "venv", - MCP_SECRETS_FILENAME, + MCP_SECRETS_FILENAME, # Exclude mcp_agent.secrets.yaml only }: ignored.add(name) + + # Apply explicit ignore file patterns (if provided) + spec_ignored = should_ignore_by_gitignore( + path_str, names, project_dir, ignore_spec + ) + ignored.update(spec_ignored) + return ignored shutil.copytree(project_dir, temp_project_dir, ignore=ignore_patterns) @@ -209,6 +239,26 @@ def ignore_patterns(_path, names): # Rename in place file_path.rename(py_path) + # Compute and log which original files are being bundled (skip internal helpers) + bundled_original_files: list[str] = [] + internal_bundle_files = {"wrangler.toml", "mcp_deploy_breadcrumb.py"} + for root, _dirs, files in os.walk(temp_project_dir): + for filename in files: + rel = Path(root).relative_to(temp_project_dir) / filename + if filename in internal_bundle_files: + continue + if filename.endswith(".mcpac.py"): + orig_rel = str(rel)[: -len(".mcpac.py")] + bundled_original_files.append(orig_rel) + else: + bundled_original_files.append(str(rel)) + + bundled_original_files.sort() + if bundled_original_files: + print_info(f"Bundling {len(bundled_original_files)} project file(s):") + for p in bundled_original_files: + console.print(f" - {p}") + # Collect deployment metadata (git if available, else workspace hash) git_meta = get_git_metadata(project_dir) deploy_source = "git" if git_meta else "workspace" diff --git a/tests/cli/commands/test_deploy_command.py b/tests/cli/commands/test_deploy_command.py index c73ebe2ba..cd7c26867 100644 --- a/tests/cli/commands/test_deploy_command.py +++ b/tests/cli/commands/test_deploy_command.py @@ -74,6 +74,8 @@ def test_deploy_command_help(runner): assert "--api-url" in clean_text assert "--api-key" in clean_text assert "--non-interactive" in clean_text + assert "--ignore-file" in clean_text + assert "mcpacignore" in clean_text def test_deploy_command_basic(runner, temp_config_dir): @@ -115,22 +117,22 @@ async def mock_process_secrets(*args, **kwargs): "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", return_value=MOCK_APP_ID, ), - ): - # Run the deploy command - result = runner.invoke( - app, - [ - "deploy", - MOCK_APP_NAME, - "--config-dir", - temp_config_dir, - "--api-url", - "http://test-api.com", - "--api-key", - "test-api-key", - "--non-interactive", # Prevent prompting for input - ], - ) + ): + # Run the deploy command + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + temp_config_dir, + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--non-interactive", # Prevent prompting for input + ], + ) # Check command exit code assert result.exit_code == 0, f"Deploy command failed: {result.stdout}" @@ -388,6 +390,343 @@ async def mock_process_secrets(*args, **kwargs): assert create_call.kwargs.get("description") is None +def test_deploy_auto_detects_mcpacignore(runner, temp_config_dir): + """A `.mcpacignore` that lives beside the config dir is auto-detected. + + The CLI should discover the file without extra flags, resolve it to an + absolute path, and hand that path through to `wrangler_deploy` so the + bundler applies the expected ignore patterns. + """ + default_ignore = temp_config_dir / ".mcpacignore" + default_ignore.write_text("*.log\n") + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = None + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + mock_client.create_app.return_value = mock_app + + captured = {} + + def _capture_wrangler(app_id, api_key, project_dir, ignore_file=None): + captured["ignore_file"] = ignore_file + return MOCK_APP_ID + + async def _fake_process_config_secrets(*_args, **_kwargs): + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + with ( + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + side_effect=_capture_wrangler, + ), + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=_fake_process_config_secrets, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + str(temp_config_dir), + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--non-interactive", + ], + ) + + assert result.exit_code == 0, result.stdout + ignore_path = captured.get("ignore_file") + assert ignore_path is not None + assert ignore_path.resolve() == default_ignore.resolve() + + +def test_deploy_uses_cwd_mcpacignore_when_config_dir_lacks_one( + runner, temp_config_dir, monkeypatch +): + """Fallback to the working directory's ignore file when config_dir has none. + + When the project directory does not contain `.mcpacignore`, the CLI should + look in `Path.cwd()` and forward that file to the bundler, ensuring teams + can keep ignore rules in the working tree root. + """ + default_ignore = temp_config_dir / ".mcpacignore" + if default_ignore.exists(): + default_ignore.unlink() + + with tempfile.TemporaryDirectory() as cwd_dir: + cwd_path = Path(cwd_dir) + monkeypatch.chdir(cwd_path) + + cwd_ignore = cwd_path / ".mcpacignore" + cwd_ignore.write_text("*.tmp\n") + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = None + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + mock_client.create_app.return_value = mock_app + + captured = {} + + def _capture_wrangler(app_id, api_key, project_dir, ignore_file=None): + captured["ignore_file"] = ignore_file + return MOCK_APP_ID + + async def _fake_process_config_secrets(*_args, **_kwargs): + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + with ( + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + side_effect=_capture_wrangler, + ), + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=_fake_process_config_secrets, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + str(temp_config_dir), + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--non-interactive", + ], + ) + + assert result.exit_code == 0, result.stdout + ignore_path = captured.get("ignore_file") + assert ignore_path is not None + assert ignore_path.resolve() == cwd_ignore.resolve() + + +def test_deploy_no_ignore_when_file_missing(runner, temp_config_dir): + """No ignore file is used when neither `.mcpacignore` nor `--ignore-file` exists. + + Ensures the CLI passes `None` to `wrangler_deploy`, meaning only the built-in + exclusions run when there is no ignore file anywhere on disk. + """ + default_ignore = temp_config_dir / ".mcpacignore" + if default_ignore.exists(): + default_ignore.unlink() + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = None + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + mock_client.create_app.return_value = mock_app + + captured = {} + + def _capture_wrangler(app_id, api_key, project_dir, ignore_file=None): + captured["ignore_file"] = ignore_file + return MOCK_APP_ID + + async def _fake_process_config_secrets(*_args, **_kwargs): + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + with ( + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + side_effect=_capture_wrangler, + ), + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=_fake_process_config_secrets, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + str(temp_config_dir), + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--non-interactive", + ], + ) + + assert result.exit_code == 0, result.stdout + assert captured.get("ignore_file") is None + + +def test_deploy_ignore_file_custom(runner, temp_config_dir): + """`--ignore-file` should win over auto-detection and stay intact. + + Confirms the CLI resolves the user-supplied path flag and forwards that + absolute location to `wrangler_deploy` unmodified. + """ + custom_ignore = temp_config_dir / ".deployignore" + custom_ignore.write_text("*.tmp\n") + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = None + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + mock_client.create_app.return_value = mock_app + + captured = {} + + def _capture_wrangler(app_id, api_key, project_dir, ignore_file=None): + captured["ignore_file"] = ignore_file + return MOCK_APP_ID + + async def _fake_process_config_secrets(*_args, **_kwargs): + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + with ( + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + side_effect=_capture_wrangler, + ), + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=_fake_process_config_secrets, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + str(temp_config_dir), + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--non-interactive", + "--ignore-file", + str(custom_ignore), + ], + ) + + assert result.exit_code == 0, result.stdout + ignore_path = captured.get("ignore_file") + assert ignore_path is not None + assert ignore_path.resolve() == custom_ignore.resolve() + + +def test_deploy_ignore_file_overrides_default(runner, temp_config_dir): + """`--ignore-file` overrides any `.mcpacignore` located on disk. + + With both files present, the bundler should receive the explicit flag’s + path, proving that manual overrides take precedence over defaults. + """ + default_ignore = temp_config_dir / ".mcpacignore" + default_ignore.write_text("*.log\n") + custom_ignore = temp_config_dir / ".customignore" + custom_ignore.write_text("*.tmp\n") + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = None + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + mock_client.create_app.return_value = mock_app + + captured = {} + + def _capture_wrangler(app_id, api_key, project_dir, ignore_file=None): + captured["ignore_file"] = ignore_file + return MOCK_APP_ID + + async def _fake_process_config_secrets(*_args, **_kwargs): + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + with ( + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + side_effect=_capture_wrangler, + ), + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=_fake_process_config_secrets, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + str(temp_config_dir), + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--non-interactive", + "--ignore-file", + str(custom_ignore), + ], + ) + + assert result.exit_code == 0, result.stdout + ignore_path = captured.get("ignore_file") + assert ignore_path is not None + assert ignore_path.resolve() == custom_ignore.resolve() + + def test_deploy_with_secrets_file(): """Test the deploy command with a secrets file.""" # Create a temporary directory for test files diff --git a/tests/cli/commands/test_wrangler_wrapper.py b/tests/cli/commands/test_wrangler_wrapper.py index da5c7b2f4..5354589d7 100644 --- a/tests/cli/commands/test_wrangler_wrapper.py +++ b/tests/cli/commands/test_wrangler_wrapper.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest +import pathspec from mcp_agent.cli.cloud.commands.deploy.validation import ( validate_entrypoint, @@ -17,6 +18,10 @@ _needs_requirements_modification, wrangler_deploy, ) +from mcp_agent.cli.cloud.commands.deploy.bundle_utils import ( + create_pathspec_from_gitignore, + should_ignore_by_gitignore, +) from mcp_agent.cli.core.constants import MCP_SECRETS_FILENAME @@ -936,6 +941,14 @@ def test_wrangler_deploy_secrets_file_exclusion(): secrets_file = project_path / MCP_SECRETS_FILENAME secrets_file.write_text(secrets_content) + # Create secrets example file + secrets_example_file = project_path / "mcp_agent.secrets.yaml.example" + secrets_example_file.write_text(""" +# Example secrets file +api_key: your_api_key_here +db_password: your_password_here +""") + # Create other YAML files that should be processed config_file = project_path / "config.yaml" config_file.write_text("name: test-app") @@ -957,6 +970,10 @@ def check_secrets_exclusion_during_subprocess(*args, **kwargs): temp_project_dir / f"{MCP_SECRETS_FILENAME}.mcpac.py" ).exists(), "Secrets file should not be processed as .mcpac.py" + assert ( + temp_project_dir / "mcp_agent.secrets.yaml.example.mcpac.py" + ).exists() + # Other YAML files should be processed normally assert (temp_project_dir / "config.yaml.mcpac.py").exists(), ( "Other YAML files should be processed as .mcpac.py" @@ -994,9 +1011,243 @@ def check_secrets_exclusion_during_subprocess(*args, **kwargs): assert secrets_file.read_text() == secrets_content, ( "Secrets file content should be preserved" ) + assert secrets_example_file.exists() assert config_file.exists(), "Config file should still exist" # No secrets-related mcpac.py files should exist in original directory assert not (project_path / f"{MCP_SECRETS_FILENAME}.mcpac.py").exists(), ( "No secrets .mcpac.py file should exist in original directory" ) + + +# Bundle utils tests +def test_should_ignore_by_gitignore(): + """Exercise ignore matching for mixed files and directories. + + Builds a `PathSpec` with file globs and directory suffixes and verifies the + adapter returns only the names that match those patterns, covering the + core filtering logic used during bundle copies. + """ + + gitignore_content = """*.log +*.pyc +node_modules/ +temp/ +build/ +""" + + # Create a mock PathSpec directly + spec = pathspec.PathSpec.from_lines('gitwildmatch', gitignore_content.splitlines()) + + project_dir = Path("/fake/project") + current_path = str(project_dir) + names = ["test.log", "main.py", "node_modules", "config.yaml", "test.pyc"] + + # Mock Path.is_dir method properly + original_is_dir = Path.is_dir + Path.is_dir = lambda self: self.name in ["node_modules", "temp", "build"] + + try: + ignored = should_ignore_by_gitignore(current_path, names, project_dir, spec) + finally: + # Restore original method + Path.is_dir = original_is_dir + + assert "test.log" in ignored + assert "test.pyc" in ignored + assert "node_modules" in ignored + assert "main.py" not in ignored + assert "config.yaml" not in ignored + + +def test_create_pathspec_from_gitignore(tmp_path): + """`create_pathspec_from_gitignore` should parse patterns into a matcher. + + Writes a temporary ignore file, loads it into a `PathSpec`, and asserts the + resulting matcher includes and excludes representative paths. + """ + + ignore_path = tmp_path / ".mcpacignore" + ignore_path.write_text("*.log\nbuild/\n") + + spec = create_pathspec_from_gitignore(ignore_path) + + assert spec is not None + assert spec.match_file("debug.log") + assert spec.match_file("build/output.txt") + assert not spec.match_file("main.py") + + +def test_create_pathspec_from_gitignore_missing_file(tmp_path): + """Missing ignore files must return `None`. + + Ensures callers can detect the absence of an ignore file and fall back to + default behaviour without raising. + """ + + missing_path = tmp_path / ".doesnotexist" + assert create_pathspec_from_gitignore(missing_path) is None + + +def test_should_ignore_by_gitignore_without_spec(tmp_path): + """When no spec is provided the adapter should ignore nothing. + + Verifies the helper returns an empty set so the copy operation only applies + the hard-coded exclusions. + """ + + project_dir = tmp_path + (project_dir / "data.txt").write_text("data") + + ignored = should_ignore_by_gitignore( + str(project_dir), ["data.txt"], project_dir, spec=None + ) + + assert ignored == set() + + +def test_should_ignore_by_gitignore_matches_directories(tmp_path): + """Directory patterns like `build/` must match folder names. + + Confirms the helper rewrites directory paths with a trailing slash when + checking patterns so gitignore-style directory globs are honoured. + """ + + project_dir = tmp_path + (project_dir / "build").mkdir() + spec = pathspec.PathSpec.from_lines("gitwildmatch", ["build/"]) + + ignored = should_ignore_by_gitignore( + str(project_dir), ["build"], project_dir, spec + ) + + assert "build" in ignored + + +def test_should_ignore_by_gitignore_handles_nested_paths(tmp_path): + """Nested patterns should be evaluated relative to the project root. + + Demonstrates that patterns such as `assets/*.txt` apply to files in a + subdirectory while sparing siblings that do not match. + """ + + project_dir = tmp_path + nested = project_dir / "assets" + nested.mkdir() + (nested / "notes.txt").write_text("notes") + (nested / "keep.md").write_text("keep") + + spec = pathspec.PathSpec.from_lines("gitwildmatch", ["assets/*.txt"]) + + ignored = should_ignore_by_gitignore( + str(nested), ["notes.txt", "keep.md"], project_dir, spec + ) + + assert "notes.txt" in ignored + assert "keep.md" not in ignored + + +def test_wrangler_deploy_with_ignore_file(): + """Bundling honours explicit ignore file patterns end to end. + + Creates a project containing included and excluded files, supplies a real + `.mcpacignore`, and checks the temp bundle only contains files that should + survive, proving the ignore spec is wired into `copytree` correctly. + """ + with tempfile.TemporaryDirectory() as temp_dir: + project_path = Path(temp_dir) + + # Create main.py + (project_path / "main.py").write_text(""" +from mcp_agent_cloud import MCPApp +app = MCPApp(name="test-app") +""") + + # Create .mcpacignore + ignore_content = """*.log +*.tmp +build/ +dist/ +*.pyc +""" + (project_path / ".mcpacignore").write_text(ignore_content) + + # Create files that should be ignored + (project_path / "debug.log").write_text("log content") + (project_path / "temp.tmp").write_text("temp content") + (project_path / "cache.pyc").write_text("pyc content") + + build_dir = project_path / "build" + build_dir.mkdir() + (build_dir / "output.txt").write_text("build output") + + # Create files that should be included + (project_path / "config.yaml").write_text("config: value") + (project_path / "data.txt").write_text("data content") + + def check_gitignore_respected(*args, **kwargs): + temp_project_dir = Path(kwargs["cwd"]) + + # Files matching gitignore should NOT be copied + assert not (temp_project_dir / "debug.log").exists() + assert not (temp_project_dir / "temp.tmp").exists() + assert not (temp_project_dir / "cache.pyc").exists() + assert not (temp_project_dir / "build").exists() + + # Files not matching gitignore should be copied + assert (temp_project_dir / "main.py").exists() + assert (temp_project_dir / "config.yaml.mcpac.py").exists() + assert (temp_project_dir / "data.txt.mcpac.py").exists() + + return MagicMock(returncode=0) + + with patch("subprocess.run", side_effect=check_gitignore_respected): + wrangler_deploy("test-app", "test-api-key", project_path, project_path / ".mcpacignore") + + +def test_wrangler_deploy_warns_when_ignore_file_missing(): + """Missing ignore files should warn but still bundle everything. + + Passes a nonexistent ignore path, asserts `print_warning` reports the issue, + and that the temporary bundle still includes files that would only be + skipped by an actual ignore spec. + """ + + with tempfile.TemporaryDirectory() as temp_dir: + project_path = Path(temp_dir) + + (project_path / "main.py").write_text( + """ +from mcp_agent_cloud import MCPApp + +app = MCPApp(name="test-app") +""" + ) + (project_path / "config.yaml").write_text("name: test-app\n") + (project_path / "artifact.txt").write_text("artifact\n") + + missing_ignore = project_path / ".customignore" + + def check_missing_ignore_behavior(*args, **kwargs): + temp_project_dir = Path(kwargs["cwd"]) + + # Nothing should be ignored beyond defaults when the file is missing + assert (temp_project_dir / "artifact.txt.mcpac.py").exists() + assert (temp_project_dir / "config.yaml.mcpac.py").exists() + + return MagicMock(returncode=0) + + with ( + patch( + "mcp_agent.cli.cloud.commands.deploy.wrangler_wrapper.print_warning" + ) as mock_warning, + patch("subprocess.run", side_effect=check_missing_ignore_behavior), + ): + wrangler_deploy( + "test-app", "test-api-key", project_path, missing_ignore + ) + + mock_warning.assert_called_once() + warning_message = mock_warning.call_args[0][0] + assert str(missing_ignore) in warning_message + assert "not found" in warning_message diff --git a/uv.lock b/uv.lock index 7cdf2085f..22b1441fa 100644 --- a/uv.lock +++ b/uv.lock @@ -2085,6 +2085,7 @@ bedrock = [ cli = [ { name = "httpx" }, { name = "hvac" }, + { name = "pathspec" }, { name = "pyjwt" }, { name = "typer" }, { name = "watchdog" }, @@ -2148,6 +2149,7 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.29.0" }, { name = "opentelemetry-instrumentation-anthropic", specifier = ">=0.39.3" }, { name = "opentelemetry-instrumentation-openai", specifier = ">=0.39.3" }, + { name = "pathspec", marker = "extra == 'cli'", specifier = ">=0.12.1" }, { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "pydantic", specifier = ">=2.10.4" }, { name = "pydantic-settings", specifier = ">=2.7.0" }, @@ -2899,6 +2901,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pdfminer-six" version = "20250327"