Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ mcp-agent deploy <APP_NAME> [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 <path>` | Use a specific ignore file (gitignore syntax). Defaults to `.mcpacignore` if present. | None |

**Examples:**
```bash
Expand All @@ -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 <path>` (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.
Expand Down Expand Up @@ -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)
- [Examples](https://github.com/lastmile-ai/mcp-agent/tree/main/examples)
2 changes: 2 additions & 0 deletions docs/cloud/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ description: "[In beta] Deploy and manage AI agents as MCP servers"

<Note>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.</Note>

<Note>You can optionally exclude files from the bundle using an ignore file (gitignore syntax). Precedence: 1) `--ignore-file <path>` (explicit override), 2) `.mcpacignore` in `--config-dir`, 3) `.mcpacignore` in the working directory (CWD).</Note>

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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions src/mcp_agent/cli/cloud/commands/deploy/bundle_utils.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions src/mcp_agent/cli/cloud/commands/deploy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>, "
"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.

Expand Down Expand Up @@ -324,13 +337,24 @@ 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,
api_key=effective_api_key,
project_dir=config_dir,
mcp_app_client=mcp_app_client,
retry_count=retry_count,
ignore=ignore_path,
)
)

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
58 changes: 54 additions & 4 deletions src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment on lines +190 to 210
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify .env exclusion for secret safety.

Line 185 whitelists .env by excluding all dotfiles except .env. This could leak secrets commonly stored in .env files.

Based on past review comments, this was flagged before. Should .env be excluded by default? If .env needs to be bundled for specific use cases, consider documenting why.


🏁 Script executed:

#!/bin/bash
# Check if .env is mentioned in documentation or tests as intentionally bundled
rg -n "\.env" docs/ README.md --type md -C2

Length of output: 760


🏁 Script executed:

#!/bin/bash
# Check if .env is in .gitignore (standard practice)
if [ -f .gitignore ]; then
  echo "=== .gitignore contents related to .env ==="
  rg -n "\.env" .gitignore
else
  echo ".gitignore not found"
fi

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Find and examine the should_ignore_by_gitignore function implementation
echo "=== Searching for should_ignore_by_gitignore function ==="
ast-grep --pattern 'def should_ignore_by_gitignore($$$) {
  $$$
}'

Length of output: 57


🏁 Script executed:

#!/bin/bash
# Look for any tests or documentation about deployment exclusions
echo "=== Searching for deployment-related tests ==="
rg -n "ignore_patterns|\.env" --type py -g '*test*' -C3

Length of output: 14340


🏁 Script executed:

#!/bin/bash
# Check for any other references to .env handling in deployment code
echo "=== Searching for .env in deployment-related code ==="
rg -n "\.env" src/mcp_agent/cli/cloud/commands/deploy/ --type py -C2

Length of output: 1517


🏁 Script executed:

#!/bin/bash
# Get the full test context around line 412 to see what's actually being tested
echo "=== Full test context for .env handling ==="
rg -n "def test.*" tests/cli/commands/test_wrangler_wrapper.py -A 50 | rg -A 50 "Check that hidden files"

Length of output: 2543


🏁 Script executed:

#!/bin/bash
# Look for any assertion about .env in the test file
echo "=== Searching for .env assertions in wrangler tests ==="
rg -n "\.env" tests/cli/commands/test_wrangler_wrapper.py -B2 -A2

Length of output: 257


🏁 Script executed:

#!/bin/bash
# Check if the test fixture creates a .env file
echo "=== Searching for .env in test fixtures and conftest ==="
rg -n "\.env" tests/cli/commands/test_wrangler_wrapper.py tests/cli/commands/conftest.py -B3 -A3

Length of output: 763


🏁 Script executed:

#!/bin/bash
# Look for the complex_project_structure fixture definition
echo "=== Finding complex_project_structure fixture ==="
ast-grep --pattern 'def complex_project_structure($$$):
  $$$'

Length of output: 4461


CRITICAL: Remove .env from deployment whitelist to prevent secret leakage.

Line 185 explicitly whitelists .env files for deployment by excluding all dotfiles except .env. This creates a critical security vulnerability:

  1. .env is gitignored, confirming it contains secrets not meant for version control
  2. README states .env is for local development and "In production, prefer MCP_APP_SETTINGS_PRELOAD to avoid writing plaintext secrets to disk"
  3. .env contains API keys like OPENAI_API_KEY and ANTHROPIC_API_KEY

If a developer has a local .env file (common during development), it will be bundled into the Cloudflare Workers deployment, exposing production secrets.

Required fix:

if name.startswith(".") or name in {
    "logs",
    "__pycache__",
    "node_modules", 
    "venv",
    MCP_SECRETS_FILENAME,
}:
    ignored.add(name)

Remove .env from the exception list—all dotfiles should be excluded from deployment.

🤖 Prompt for AI Agents
In src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py around lines 180
to 200, the current logic explicitly whitelists “.env” by excluding all dotfiles
except ".env", which risks bundling local secret files into deployments; change
the condition so that all dotfiles are treated as ignored (remove ".env" from
the exception list) and update the surrounding comment to reflect that all
dotfiles are excluded to prevent secret leakage; ensure the ignored.add(name)
branch triggers for any name that starts with "." (no exceptions) while
preserving the other hardcoded exclusions (logs, __pycache__, node_modules,
venv, MCP_SECRETS_FILENAME).


shutil.copytree(project_dir, temp_project_dir, ignore=ignore_patterns)
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading