Skip to content

Commit 75935f6

Browse files
authored
feat: cloud deploy file ignore functionality (#511)
* feat: bundle comments and file ignore * fix: tests * feat: ignore secret example * feat: add pathsec dep * refactor: ignore without code cleanup * fix: example file should be included * feat: doc updates * fix: use cur dir * feat: default to .mcpacignore * fix: file logs minimal * fix: console print for file list * fix: lint and comments * feat: more tests * fix: docs and error logic
1 parent c2d85ef commit 75935f6

File tree

9 files changed

+790
-21
lines changed

9 files changed

+790
-21
lines changed

docs/cli-reference.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ mcp-agent deploy <APP_NAME> [OPTIONS]
251251
| `--dry-run` | Validate without deploying | `false` |
252252
| `--api-url` | API base URL | `https://deployments.mcp-agent.com` |
253253
| `--api-key` | API key | From env or config |
254+
| `--ignore-file <path>` | Use a specific ignore file (gitignore syntax). Defaults to `.mcpacignore` if present. | None |
254255

255256
**Examples:**
256257
```bash
@@ -264,8 +265,16 @@ mcp-agent deploy my-agent \
264265

265266
# Dry run
266267
mcp-agent deploy my-agent --dry-run
268+
269+
# Use a custom ignore file
270+
mcp-agent deploy my-agent --ignore-file .deployignore
267271
```
268272

273+
Ignore file usage
274+
275+
- Detection order: 1) `--ignore-file <path>` (highest precedence), 2) `.mcpacignore` in `--config-dir`, 3) `.mcpacignore` in the working directory (CWD).
276+
- Patterns follow gitignore syntax and are evaluated relative to `--config-dir`.
277+
269278
### mcp-agent cloud configure
270279

271280
Configure access to a deployed MCP app.
@@ -489,4 +498,4 @@ mcp-agent deploy my-agent --dry-run
489498
- [Configuration Guide](/configuration)
490499
- [Cloud Deployment](/cloud/getting-started)
491500
- [Workflow Patterns](/workflows/overview)
492-
- [Examples](https://github.com/lastmile-ai/mcp-agent/tree/main/examples)
501+
- [Examples](https://github.com/lastmile-ai/mcp-agent/tree/main/examples)

docs/cloud/overview.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ description: "[In beta] Deploy and manage AI agents as MCP servers"
2424

2525
<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>
2626

27+
<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>
28+
2729
2. Make sure you have either a `pyproject.toml` or a `requirements.txt` file in your app directory.
2830

2931
3. Mark your functions that you'd like to be tool calls with the `@app.tool` decorators

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ crewai = [
7676
cli = [
7777
"hvac>=1.1.1",
7878
"httpx>=0.28.1",
79+
"pathspec>=0.12.1",
7980
"pyjwt>=2.10.1",
8081
"typer[all]>=0.15.3",
8182
"watchdog>=6.0.0"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Ignore-file helpers for the deploy bundler.
2+
3+
This module focuses on two things:
4+
- Parse an ignore file (gitignore-compatible syntax) into a `PathSpec` matcher.
5+
- Provide an adapter that works with `shutil.copytree(ignore=...)` to decide
6+
which directory entries to skip during a copy.
7+
8+
There is no implicit reading of `.gitignore` here. Callers must explicitly
9+
pass the ignore file path they want to use (e.g., `.mcpacignore`).
10+
"""
11+
12+
from pathlib import Path
13+
from typing import Optional, Set
14+
import pathspec
15+
16+
17+
def create_pathspec_from_gitignore(ignore_file_path: Path) -> Optional[pathspec.PathSpec]:
18+
"""Create and return a `PathSpec` from an ignore file.
19+
20+
The file is parsed using the `gitwildmatch` (gitignore) syntax. If the file
21+
does not exist, `None` is returned so callers can fall back to default
22+
behavior.
23+
24+
Args:
25+
ignore_file_path: Path to the ignore file (e.g., `.mcpacignore`).
26+
27+
Returns:
28+
A `PathSpec` that can match file/directory paths, or `None`.
29+
"""
30+
if not ignore_file_path.exists():
31+
return None
32+
33+
with open(ignore_file_path, "r", encoding="utf-8") as f:
34+
spec = pathspec.PathSpec.from_lines("gitwildmatch", f)
35+
36+
return spec
37+
38+
39+
40+
def should_ignore_by_gitignore(
41+
path_str: str, names: list, project_dir: Path, spec: Optional[pathspec.PathSpec]
42+
) -> Set[str]:
43+
"""Return the subset of `names` to ignore for `shutil.copytree`.
44+
45+
This function is designed to be passed as the `ignore` callback to
46+
`shutil.copytree`. For each entry in the current directory (`path_str`), it
47+
computes the path relative to the `project_dir` root and checks it against
48+
the provided `spec` (a `PathSpec` created from an ignore file).
49+
50+
Notes:
51+
- If `spec` is `None`, this returns an empty set (no additional ignores).
52+
- For directories, we also check the relative path with a trailing slash
53+
(a common gitignore convention).
54+
"""
55+
if spec is None:
56+
return set()
57+
58+
ignored: Set[str] = set()
59+
current_path = Path(path_str)
60+
61+
for name in names:
62+
full_path = current_path / name
63+
try:
64+
rel_path = full_path.relative_to(project_dir)
65+
except ValueError:
66+
# If `full_path` is not under `project_dir`, ignore matching is skipped.
67+
continue
68+
69+
# Normalize to POSIX separators so patterns work cross-platform (Windows too)
70+
rel_path_str = rel_path.as_posix()
71+
72+
# Match files exactly; for directories also try with a trailing slash
73+
# to respect patterns like `build/`.
74+
if spec.match_file(rel_path_str):
75+
ignored.add(name)
76+
elif full_path.is_dir() and spec.match_file(rel_path_str + "/"):
77+
ignored.add(name)
78+
79+
80+
return ignored

src/mcp_agent/cli/cloud/commands/deploy/main.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@ def deploy_config(
116116
min=1,
117117
max=10,
118118
),
119+
ignore_file: Optional[Path] = typer.Option(
120+
None,
121+
"--ignore-file",
122+
help=(
123+
"Path to ignore file (gitignore syntax). Precedence: 1) --ignore-file <path>, "
124+
"2) .mcpacignore in --config-dir, 3) .mcpacignore in working directory."
125+
),
126+
exists=False,
127+
readable=True,
128+
dir_okay=False,
129+
file_okay=True,
130+
resolve_path=True,
131+
),
119132
) -> str:
120133
"""Deploy an MCP agent using the specified configuration.
121134
@@ -324,13 +337,24 @@ def deploy_config(
324337
else:
325338
print_info("Skipping git tag (not a git repository)")
326339

340+
# Determine effective ignore path
341+
ignore_path: Optional[Path] = None
342+
if ignore_file is not None:
343+
ignore_path = ignore_file
344+
else:
345+
candidate = config_dir / ".mcpacignore"
346+
if not candidate.exists():
347+
candidate = Path.cwd() / ".mcpacignore"
348+
ignore_path = candidate if candidate.exists() else None
349+
327350
app = run_async(
328351
_deploy_with_retry(
329352
app_id=app_id,
330353
api_key=effective_api_key,
331354
project_dir=config_dir,
332355
mcp_app_client=mcp_app_client,
333356
retry_count=retry_count,
357+
ignore=ignore_path,
334358
)
335359
)
336360

@@ -359,6 +383,7 @@ async def _deploy_with_retry(
359383
project_dir: Path,
360384
mcp_app_client: MCPAppClient,
361385
retry_count: int,
386+
ignore: Optional[Path],
362387
):
363388
"""Execute the deployment operations with retry logic.
364389
@@ -378,6 +403,7 @@ async def _deploy_with_retry(
378403
app_id=app_id,
379404
api_key=api_key,
380405
project_dir=project_dir,
406+
ignore_file=ignore,
381407
)
382408
except Exception as e:
383409
raise CLIError(f"Bundling failed: {str(e)}") from e

src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
utc_iso_now,
1919
)
2020

21+
from .bundle_utils import (
22+
create_pathspec_from_gitignore,
23+
should_ignore_by_gitignore,
24+
)
2125
from .constants import (
2226
CLOUDFLARE_ACCOUNT_ID,
2327
CLOUDFLARE_EMAIL,
@@ -107,7 +111,9 @@ def _handle_wrangler_error(e: subprocess.CalledProcessError) -> None:
107111
print_error(clean_output)
108112

109113

110-
def wrangler_deploy(app_id: str, api_key: str, project_dir: Path) -> None:
114+
def wrangler_deploy(
115+
app_id: str, api_key: str, project_dir: Path, ignore_file: Path | None = None
116+
) -> None:
111117
"""Bundle the MCP Agent using Wrangler.
112118
113119
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:
128134
app_id (str): The application ID.
129135
api_key (str): User MCP Agent Cloud API key.
130136
project_dir (Path): The directory of the project to deploy.
137+
ignore_file (Path | None): Optional path to a gitignore-style file for excluding files from the bundle.
131138
"""
132139

133140
# Copy existing env to avoid overwriting
@@ -165,18 +172,41 @@ def wrangler_deploy(app_id: str, api_key: str, project_dir: Path) -> None:
165172
with tempfile.TemporaryDirectory(prefix="mcp-deploy-") as temp_dir_str:
166173
temp_project_dir = Path(temp_dir_str) / "project"
167174

168-
# Copy the entire project to temp directory, excluding unwanted directories and secrets file
169-
def ignore_patterns(_path, names):
175+
# Load ignore rules (gitignore syntax) only if an explicit ignore file is provided
176+
ignore_spec = (
177+
create_pathspec_from_gitignore(ignore_file) if ignore_file else None
178+
)
179+
if ignore_file:
180+
if ignore_spec is None:
181+
print_warning(
182+
f"Ignore file '{ignore_file}' not found; applying default excludes only"
183+
)
184+
else:
185+
print_info(f"Using ignore patterns from {ignore_file}")
186+
else:
187+
print_info("No ignore file provided; applying default excludes only")
188+
189+
# Copy the entire project to temp directory, excluding unwanted directories and the live secrets file
190+
def ignore_patterns(path_str, names):
170191
ignored = set()
192+
193+
# Keep existing hardcoded exclusions (highest priority)
171194
for name in names:
172195
if (name.startswith(".") and name not in {".env"}) or name in {
173196
"logs",
174197
"__pycache__",
175198
"node_modules",
176199
"venv",
177-
MCP_SECRETS_FILENAME,
200+
MCP_SECRETS_FILENAME, # Exclude mcp_agent.secrets.yaml only
178201
}:
179202
ignored.add(name)
203+
204+
# Apply explicit ignore file patterns (if provided)
205+
spec_ignored = should_ignore_by_gitignore(
206+
path_str, names, project_dir, ignore_spec
207+
)
208+
ignored.update(spec_ignored)
209+
180210
return ignored
181211

182212
shutil.copytree(project_dir, temp_project_dir, ignore=ignore_patterns)
@@ -209,6 +239,26 @@ def ignore_patterns(_path, names):
209239
# Rename in place
210240
file_path.rename(py_path)
211241

242+
# Compute and log which original files are being bundled (skip internal helpers)
243+
bundled_original_files: list[str] = []
244+
internal_bundle_files = {"wrangler.toml", "mcp_deploy_breadcrumb.py"}
245+
for root, _dirs, files in os.walk(temp_project_dir):
246+
for filename in files:
247+
rel = Path(root).relative_to(temp_project_dir) / filename
248+
if filename in internal_bundle_files:
249+
continue
250+
if filename.endswith(".mcpac.py"):
251+
orig_rel = str(rel)[: -len(".mcpac.py")]
252+
bundled_original_files.append(orig_rel)
253+
else:
254+
bundled_original_files.append(str(rel))
255+
256+
bundled_original_files.sort()
257+
if bundled_original_files:
258+
print_info(f"Bundling {len(bundled_original_files)} project file(s):")
259+
for p in bundled_original_files:
260+
console.print(f" - {p}")
261+
212262
# Collect deployment metadata (git if available, else workspace hash)
213263
git_meta = get_git_metadata(project_dir)
214264
deploy_source = "git" if git_meta else "workspace"

0 commit comments

Comments
 (0)