Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
9 changes: 8 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[=<path>]` | Ignore files using gitignore syntax. Bare `--ignore` uses `.mcpacignore` in CWD; use `--ignore=<path>` for a custom file. | Not used |

**Examples:**
```bash
Expand All @@ -264,6 +265,12 @@ mcp-agent deploy my-agent \

# Dry run
mcp-agent deploy my-agent --dry-run

# Use an ignore file (default name)
mcp-agent deploy my-agent --ignore

# Use a custom ignore file
mcp-agent deploy my-agent --ignore=.deployignore
```

### mcp-agent cloud configure
Expand Down Expand Up @@ -489,4 +496,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 with gitignore syntax. Use `--ignore` to look for `.mcpacignore` in your project directory, or `--ignore=<path>` to specify a different file.</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
77 changes: 77 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,77 @@
"""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)
rel_path_str = str(rel_path)

# 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)
except ValueError:
# If `full_path` is not under `project_dir`, ignore matching is skipped.
continue

return ignored
17 changes: 17 additions & 0 deletions src/mcp_agent/cli/cloud/commands/deploy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ def deploy_config(
min=1,
max=10,
),
ignore: Optional[Path] = typer.Option(
None,
"--ignore",
help=(
"Ignore files using gitignore syntax. Bare --ignore uses .mcpacignore in CWD; "
"use --ignore=<path> to specify a custom file."
),
flag_value=Path(".mcpacignore"),
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 @@ -289,6 +303,7 @@ def deploy_config(
project_dir=config_dir,
mcp_app_client=mcp_app_client,
retry_count=retry_count,
ignore=ignore,
)
)

Expand Down Expand Up @@ -317,6 +332,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 @@ -336,6 +352,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
65 changes: 62 additions & 3 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 Down Expand Up @@ -156,18 +162,42 @@ 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"

# 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 secrets file
def ignore_patterns(_path, names):
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, # Continue excluding mcp_agent.secrets.yaml
f"{MCP_SECRETS_FILENAME}.example", # Exclude mcp_agent.secrets.yaml.example
}:
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)
Expand Down Expand Up @@ -201,6 +231,34 @@ def ignore_patterns(_path, names):
# Rename in place
file_path.rename(py_path)

# Compute and log which original files are being bundled
bundled_original_files = []
added_deployment_files = []
for root, _dirs, files in os.walk(temp_project_dir):
for filename in files:
rel = Path(root).relative_to(temp_project_dir) / filename
# Map transient .mcpac.py back to the original filename for logging
if filename.endswith(".mcpac.py"):
orig_rel = str(rel)[: -len(".mcpac.py")]
bundled_original_files.append(orig_rel)
elif filename in {"wrangler.toml", "mcp_deploy_breadcrumb.py"}:
added_deployment_files.append(str(rel))
else:
# Regular .py file or other file we didn't rename
bundled_original_files.append(str(rel))

# Sort for stable output
bundled_original_files.sort()
added_deployment_files.sort()

print_info(f"Bundling {len(bundled_original_files)} project file(s):")
for p in bundled_original_files:
print_info(f" - {p}")
if added_deployment_files:
print_info("Including deployment helper files:")
for p in added_deployment_files:
print_info(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 Expand Up @@ -233,6 +291,7 @@ def ignore_patterns(_path, names):
"node_modules",
"venv",
MCP_SECRETS_FILENAME,
f"{MCP_SECRETS_FILENAME}.example",
},
)
meta_vars.update({"MCP_DEPLOY_WORKSPACE_HASH": bundle_hash})
Expand Down
Loading
Loading