fix(install): handle bare-string MCP entry in shell-metachar warning#951
fix(install): handle bare-string MCP entry in shell-metachar warning#951edenfunf wants to merge 13 commits intomicrosoft:mainfrom
Conversation
`apm install --mcp <ref>` without --transport, --url, --mcp-version,
--registry, or post-`--` argv returns a bare string from the entry
builder per the apm.yml shorthand contract. The F7 shell-metachar
warning callsite then read `entry.get("command")` unguarded, raising
`'str' object has no attribute 'get'` and aborting the install before
apm.yml could be written.
Source the stdio command from `command_argv` (the CLI input that would
have populated `entry["command"]` in the dict-entry path) rather than
introspecting the post-build entry. The two values are identical in
the stdio path and both yield `None` elsewhere, so warning behaviour is
preserved bit-for-bit while the str-vs-dict dispatch disappears from
this callsite. Other already-guarded `isinstance(entry, dict)`
callsites in `_run_mcp_install` are left as-is to keep the diff
minimal.
Closes microsoft#938
APM Review Panel VerdictDisposition: APPROVE (with one lightweight pre-merge required action: add CHANGELOG entry) Per-persona findingsPython Architect: This PR is purely procedural -- OO / class diagram (routine PR -- one function touched): classDiagram
direction LR
class InstallModule {
<<Pure>>
+_build_mcp_entry(name, ...) str_or_dict
+_run_mcp_install(...) void
}
class MCPWarningsModule {
<<Pure>>
+warn_shell_metachars(env, logger, command) void
+warn_ssrf_url(url, logger) void
}
class MCPDependency {
<<Model>>
+from_string(name) MCPDependency
+from_dict(entry) MCPDependency
}
class MCPIntegrator {
<<Integrator>>
+install(deps, ...) void
}
InstallModule ..> MCPWarningsModule : F5 and F7 warnings
InstallModule ..> MCPDependency : validates entry
InstallModule ..> MCPIntegrator : deploys
note for InstallModule "_build_mcp_entry returns str or dict\n_run_mcp_install must not assume dict\nat warning callsite -- 3 of 4 callsites\nalready guarded with isinstance check"
class InstallModule:::touched
classDef touched fill:#fff3b0,stroke:#d47600
Execution flow (Before / After -- before path crashes before any FS write): Before: flowchart TD
A["User: apm install --mcp name\nno --transport, no double-dash"] --> B["_run_mcp_install\ninstall.py:809"]
B --> C["_build_mcp_entry\ninstall.py:513\nreturns bare str"]
C --> D["_warn_ssrf_url url=None\nmcp_warnings.py:75"]
D --> E["entry.get command\ninstall.py:852 BEFORE FIX"]
E --> F["AttributeError: str object has no attribute get\nEXIT 1 -- no apm.yml written"]
After: flowchart TD
A["User: apm install --mcp name\nno --transport, no double-dash"] --> B["_run_mcp_install\ninstall.py:809"]
B --> C["_build_mcp_entry\ninstall.py:513\nreturns bare str"]
C --> D["_warn_ssrf_url url=None\nmcp_warnings.py:75"]
D --> E["stdio_command = command_argv[0] if command_argv else None\n-> None for bare shorthand\ninstall.py:857 AFTER FIX"]
E --> F["_warn_shell_metachars env, logger, command=None\nmcp_warnings.py:93 -- guard exits cleanly on None"]
F --> G["_add_mcp_to_apm_yml name, str_entry\ninstall.py:861 FS write"]
G --> H["MCPDependency.from_string entry\ninstall.py:876 validates"]
H --> I["MCPIntegrator.install deps\ninstall.py:894"]
I --> J["EXIT 0 -- bare str persisted to apm.yml"]
Design patterns
CLI Logging Expert: No logging regressions. The fix passes DevX UX Expert: Before this fix, Supply Chain Security Expert: The F7 warning is informational only -- warns, does not gate installation. In the stdio path, Auth Expert: Not activated -- the PR modifies only OSS Growth Hacker: The bare-string path ( CEO arbitrationAll specialists agree: clean, minimal, correct fix for a conversion-critical bug on the documented happy path. No disagreements to resolve. One gap requires attention before merge: the PR does not include a CHANGELOG entry, which is required by repo convention for every code-changing PR. The action is lightweight -- one line under Required actions before merge
Optional follow-ups
|
danielmeppiel
left a comment
There was a problem hiding this comment.
commands/install.py grew to 1704 LOC (budget 1700). Do NOT trim cosmetically -- engage the python-architecture skill (.github/skills/python-architecture/SKILL.md) and propose an extraction into apm_cli/install/.
commands/install.py grew to 1704 LOC, 4 over the 1700 budget enforced by tests/unit/install/test_architecture_invariants.py. Per the python-architecture skill, cosmetic trimming is the explicit anti-pattern; engage modular extraction instead. The --mcp install code path is the most cohesive contiguous block in install.py: parse args -> build entry -> validate conflicts -> warn -> write apm.yml -> integrate. Five sibling modules under install/, next to the existing mcp_warnings.py / mcp_registry.py, host the extraction: - install/mcp_args.py: parse_kv_pairs / parse_env_pairs / parse_header_pairs - install/mcp_entry.py: build_mcp_entry (str|dict tagged-union builder) - install/mcp_writer.py: add_mcp_to_apm_yml + _diff_entry - install/mcp_conflicts.py: validate_mcp_conflicts + MCP_REQUIRED_FLAGS - install/mcp_command.py: run_mcp_install orchestrator commands/install.py drops from 1704 to 1289 LOC (411 lines headroom). Back-compat preserved bit-for-bit: - All five helpers are re-exported from commands/install.py with the leading-underscore aliases the existing tests rely on, so direct imports (from apm_cli.commands.install import _build_mcp_entry) and patches (@patch("apm_cli.commands.install._run_mcp_install")) keep working unchanged. - mcp_command.py mirrors the APM_DEPS_AVAILABLE try/except pattern from commands/install.py so the success-log behaviour around a missing optional dep stays symmetric across both code paths. - mcp_registry.py's prior local-import-to-avoid-cycle of _build_mcp_entry is replaced with a sibling module-top import from mcp_entry. Cycle eliminated. The PR microsoft#951 fix (command_argv[0] if command_argv else None instead of entry.get("command")) moves intact to install/mcp_command.py:84-85. 5515 unit tests pass; test_architecture_invariants (the LOC gate) green.
Required by the apm-review-panel verdict on PR microsoft#951.
|
@danielmeppiel Thanks for the review! Both points addressed and pushed. Changes
StatusAlso merged the latest Will be more mindful of the LOC invariant on future |
…ttrerror-938 # Conflicts: # CHANGELOG.md
danielmeppiel
left a comment
There was a problem hiding this comment.
consider grouping under a mcp folder and stripping mcp_ file prefixes
Move the seven mcp_*.py helpers under apm_cli/install/ into a focused mcp/ subpackage and strip the redundant `mcp_` prefix from each filename. Aligns with the unprefixed-noun naming convention used by the rest of install/ (pipeline.py, context.py, service.py, ...). Pure refactor: behaviour, public symbol names, and the back-compat re-bound `_xxx` aliases in commands/install.py are unchanged. All install/ unit tests, the microsoft#938 regression, and the LOC budget invariant stay green.
|
@danielmeppiel done — moved Pure refactor — behaviour and public symbol names unchanged; the back-compat |
…rerror-938 # Conflicts: # CHANGELOG.md
APM Review Panel VerdictDisposition: REQUEST_CHANGES (two required pre-merge fixes; MCP subpackage extraction is excellent) Per-persona findingsPython Architect: This PR is a major architectural change across four concern areas. Diagrams cover the two most impactful structural shifts. 1. OO / class diagram -- Before / After: MCP extractionBefore (god-method in classDiagram
direction LR
class InstallCommand {
<<GodClass>>
+install_cmd(...)
+_parse_kv_pairs(...)
+_parse_env_pairs(...)
+_parse_header_pairs(...)
+_build_mcp_entry(...)
+_diff_entry(...)
+_add_mcp_to_apm_yml(...)
+_validate_mcp_conflicts(...)
}
class MCPIntegrator {
<<Integrator>>
+install(...)
+update_lockfile(...)
}
class MCPDependency {
<<ValueObject>>
+from_dict(entry)
+from_string(name)
}
class mcp_registry {
<<Module>>
+validate_registry_url()
+resolve_registry_url()
+registry_env_override()
}
class mcp_warnings {
<<Module>>
+warn_ssrf_url()
+warn_shell_metachars()
}
InstallCommand ..> mcp_registry : imports
InstallCommand ..> mcp_warnings : imports
InstallCommand ..> MCPIntegrator : calls
InstallCommand ..> MCPDependency : validates through
After (this PR): classDiagram
direction LR
class InstallCommand {
<<Coordinator>>
+install_cmd(...)
}
class run_mcp_install {
<<Orchestrator>>
+run_mcp_install(...)
}
class args {
<<Pure>>
+parse_env_pairs()
+parse_header_pairs()
}
class entry {
<<Pure>>
+build_mcp_entry()
}
class conflicts {
<<Pure>>
+validate_mcp_conflicts()
}
class registry {
<<IO/EnvManager>>
+validate_registry_url()
+resolve_registry_url()
+registry_env_override()
}
class warnings {
<<Module>>
+warn_ssrf_url()
+warn_shell_metachars()
}
class writer {
<<IO>>
+add_mcp_to_apm_yml()
}
class MCPIntegrator {
<<Integrator>>
+install(...)
}
class MCPDependency {
<<ValueObject>>
+from_dict()
+from_string()
}
InstallCommand ..> run_mcp_install : delegates
run_mcp_install *-- args : composes
run_mcp_install *-- entry : composes
run_mcp_install *-- conflicts : composes
run_mcp_install *-- registry : composes
run_mcp_install *-- warnings : composes
run_mcp_install *-- writer : composes
run_mcp_install ..> MCPIntegrator : calls
entry ..> MCPDependency : validates through
class run_mcp_install:::touched
class args:::touched
class entry:::touched
class conflicts:::touched
class registry:::touched
class warnings:::touched
class writer:::touched
classDef touched fill:#fff3b0,stroke:#d47600
1b. OO / class diagram -- Before / After: target field gatekeeperBefore: classDiagram
direction LR
class parse_target_field {
<<SharedGatekeeper>>
+parse_target_field(value, source_path) str|list|None
}
class TargetParamType {
<<ClickParamType>>
+convert(value, param, ctx)
}
class APMPackage {
<<ValueObject>>
+from_apm_yml(path) APMPackage
+target str|list|None
}
TargetParamType ..> parse_target_field : delegates (--target CLI)
APMPackage ..> parse_target_field : delegates (apm.yml target:)
note for parse_target_field "Single shared gatekeeper:\nCSV split, alias resolution,\nVALID_TARGET_VALUES check"
After (this PR -- regression): classDiagram
direction LR
class TargetParamType {
<<ClickParamType>>
+convert(value, param, ctx)
}
class APMPackage {
<<ValueObject>>
+from_apm_yml(path) APMPackage
+target raw_value_no_normalization
}
note for APMPackage "Bug: data.get('target') stored raw.\nCSV strings not split.\nNo VALID_TARGET_VALUES check.\nSilent fallback to copilot."
class APMPackage:::touched
class TargetParamType:::touched
classDef touched fill:#fff3b0,stroke:#d47600
2. Execution flow diagram -- MCP install path (After)flowchart TD
A["apm install --mcp NAME [flags]"] --> B["install_cmd() -- commands/install.py"]
B --> C{"--mcp set?"}
C -- yes --> D["validate_mcp_conflicts() -- conflicts.py [Pure]"]
D --> E{"conflict hit?"}
E -- yes --> F["click.UsageError exit 2"]
E -- no --> G["resolve_registry_url() -- registry.py [I/O env]"]
G --> H["run_mcp_install() -- command.py"]
H --> I["parse_env_pairs() / parse_header_pairs() -- args.py [Pure]"]
I --> J["build_mcp_entry() -- entry.py [Pure]"]
J --> K{"ValueError?"}
K -- yes --> L["click.UsageError exit 2"]
K -- no --> M["warn_ssrf_url() + warn_shell_metachars() -- warnings.py"]
M --> N["add_mcp_to_apm_yml() -- writer.py [FS write]"]
N --> O{"status?"}
O -- skipped --> P["logger.progress 'unchanged'"]
O -- added/replaced --> Q["registry_env_override() -- registry.py [env]"]
Q --> R["MCPIntegrator.install() [NET/FS/LOCK]"]
R --> S{"Exception?"}
S -- yes --> T["logger.warning -- torn state risk"]
S -- no --> U["MCPIntegrator.update_lockfile() [LOCK]"]
U --> V["logger.success"]
3. Design patterns
Verdict: MCP extraction is excellent architecture. The CLI Logging Expert:
DevX UX Expert:
Supply Chain Security Expert:
Auth Expert:
The marketplace was likely always primarily github.com-targeted, so this may be an acceptable simplification -- but it requires a CHANGELOG entry documenting the narrowing of scope. OSS Growth Hacker: The MCP subpackage extraction is an excellent contributor-experience improvement -- smaller, focused modules lower the barrier for community PRs on the Side-channel to CEO: Two concerns for the release narrative. (1) The CEO arbitrationThe MCP subpackage extraction is first-rate -- a clean application of APM's LOC-budget invariant, with well-scoped pure modules, correct idempotency semantics, and good security hygiene in the registry helper. That part should merge. Two required fixes block merge: First, the Second, the marketplace narrowing (hardcoded The zero-target warning removal and the Disposition: REQUEST_CHANGES on the two items above; approve the rest. Required actions before merge
Optional follow-ups
|
There was a problem hiding this comment.
Required actions before merge
Restore apm.yml target normalization (src/apm_cli/models/apm_package.py line ~206, src/apm_cli/core/target_detection.py). APMPackage.from_apm_yml() must normalize the raw target: value the same way TargetParamType.convert() does -- CSV split, alias resolution, VALID_TARGET_VALUES check -- before storing it. The simplest fix is to re-add parse_target_field() (or re-export TargetParamType-equivalent logic) and call it in from_apm_yml(). Without this, target: "claude,copilot" in apm.yml silently falls back to copilot-only with no warning.
Add CHANGELOG entry for marketplace scope narrowing. Under [Unreleased] > Changed, add a line documenting that marketplace url: sources now require https://github.com/ or `(github.com/redacted) URLs (GHES and non-GitHub URLs are no longer resolved). One line is sufficient.
writer.py calls _rich_echo() and _rich_warning() directly; these should route through the passed logger parameter to stay consistent with the CommandLogger pattern.
Restore the zero-target install warning in install/phases/targets.py (or open a tracking issue -- the comment cited #820 as the motivating rationale for that warning).
The unified loop in active_targets() / active_targets_user_scope() was replaced with duplicated isinstance(list) / str branches; consider re-unifying to a single normalization step at the top of each function.
- Restore parse_target_field() in apm_package.from_apm_yml so apm.yml target: values go through the same CSV split / alias resolution / VALID_TARGET_VALUES check as the --target CLI flag. - Restore zero-target install warning in install/phases/targets.py so a misconfigured target: no longer silently deploys nothing. - Re-unify active_targets() / active_targets_user_scope() loops in integration/targets.py via a single normalization step. - Route writer.py replacement-diff output through the injected logger (defaulting to NullCommandLogger) instead of calling _rich_echo / _rich_warning directly, restoring the CommandLogger pattern. - CHANGELOG: document the marketplace url: scope narrowing to https://github.com/ / http://github.com/ under [Unreleased] > Changed. Tests aligned with the parse_target_field behaviour are pulled in alongside (alias resolution, single-element list collapse, unknown-token rejection).
|
Pushed
Test status: 6645 unit tests pass; the 5 remaining failures are pre-existing Windows path-separator / absolute-path quirks unrelated to this change ( Re-requesting review when convenient — happy to iterate further if anything still looks off. |
…rerror-938 # Conflicts: # CHANGELOG.md
There was a problem hiding this comment.
Pull request overview
Fixes a crash in the apm install --mcp <registry-ref> “bare-string registry shorthand” path by avoiding dict-only access in the shell-metachar warning logic, and refactors the MCP install helpers into a dedicated install/mcp/ subpackage. The PR also introduces broader target-field parsing/normalization changes that affect --target and apm.yml target: handling.
Changes:
- Fix
AttributeError: 'str' object has no attribute 'get'in--mcpinstalls by sourcing the stdio command fromcommand_argvrather than the built entry. - Extract MCP install logic into
src/apm_cli/install/mcp/modules (args/conflicts/entry/registry/warnings/writer/command) and re-bind legacy helper names fromcommands/install.py. - Add a shared
parse_target_fieldvalidator and update target resolution/tests accordingly.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/install/mcp/command.py |
New orchestrator for --mcp install flow; fixes the bare-string warning crash. |
src/apm_cli/install/mcp/entry.py |
Extracted MCP entry builder returning `str |
src/apm_cli/install/mcp/warnings.py |
Extracted F5/F7 warnings used during MCP install. |
src/apm_cli/install/mcp/writer.py |
Extracted idempotent apm.yml mutation logic for MCP entries. |
src/apm_cli/install/mcp/conflicts.py |
Extracted --mcp flag conflict validation matrix. |
src/apm_cli/install/mcp/args.py |
Extracted --env / --header repetition parsing. |
src/apm_cli/install/mcp/registry.py |
Updated registry helpers to use the extracted entry builder. |
src/apm_cli/commands/install.py |
Swaps in extracted MCP helper modules while keeping legacy patch points via aliases. |
tests/unit/test_install_command.py |
Adds regression test for bare-string shorthand persistence. |
src/apm_cli/core/target_detection.py |
Adds parse_target_field and routes Click target parsing through it. |
src/apm_cli/models/apm_package.py |
Validates/normalizes apm.yml target: via parse_target_field. |
src/apm_cli/integration/targets.py |
Adjusts explicit-target handling assumptions based on upstream parsing guarantees. |
tests/unit/core/test_target_detection.py |
Updates tests for list input validation + alias collapse behavior. |
tests/unit/test_apm_package.py |
Updates target parsing expectations (alias resolution + list collapsing). |
tests/unit/integration/test_targets.py |
Updates behavior expectations around unknown targets. |
src/apm_cli/install/phases/targets.py |
Adds defensive warning when zero targets resolve. |
CHANGELOG.md |
Adds Unreleased entries for MCP crash fix and MCP module refactor (and a marketplace note). |
.github/instructions/tests.instructions.md / .apm/instructions/tests.instructions.md |
Updates doc references to new MCP registry module path. |
Comments suppressed due to low confidence (1)
CHANGELOG.md:14
- The
Unreleasedchangelog includes a Marketplace URL-resolution behavior change, but this PR's description focuses on the--mcpcrash/refactor. If the marketplace restriction is not part of this PR, it should be removed from this entry (or moved to the correct PR/version) to keep the changelog accurate; otherwise, ensure the PR description also calls out the marketplace behavior change.
- **Dev Container Feature** `ghcr.io/microsoft/apm/apm-cli` -- one-line install of the APM CLI into any `devcontainer.json`, GitHub Codespace, or JetBrains Gateway workspace. Supports a `version` option (`latest` or pinned semver), declares `installsAfter` for the official Python feature, handles PEP 668 on Ubuntu 24.04+. Ships with 37 bats unit tests and a 6-distro Docker integration matrix (Ubuntu 24.04, Ubuntu 22.04, Debian 12, Alpine 3.20, Fedora 41, plus Python-feature combo). (#861)
- `shared/apm.md` gh-aw workflow gains an `apps:` array input for cross-org private packages: each entry mints its own GitHub App installation token via `actions/create-github-app-token` and packs only its declared packages, with a matrix fan-out one replica per credential group. The single-app top-level form (`app-id`, `private-key`, `owner`, `repositories`) shipped earlier in this cycle is preserved as the canonical shorthand for one-org users; `apps[]` is purely additive. Multi-bundle restore uses the `bundles-file:` input from `microsoft/apm-action@v1.5.0` (microsoft/apm-action#30, microsoft/apm-action#29).
Type-annotate the five mcp/ helper modules extracted from commands/install.py so they match the typing convention already established by their siblings (warnings.py, registry.py). No behavioural change -- annotations only, with from __future__ import annotations already in place so types are stringified at runtime. - args.py: parse_kv_pairs / parse_env_pairs / parse_header_pairs. - entry.py: build_mcp_entry returns the explicit Tuple[Union[str, Dict[str, Any]], bool] tagged-union shape that motivated microsoft#938; future call sites that try entry.get(...) on the bare-string branch will now surface as a type-checker error. - writer.py: introduce a module-local MCPEntry alias and fully type _diff_entry / add_mcp_to_apm_yml return shape. - conflicts.py: validate_mcp_conflicts and the MCP_REQUIRED_FLAGS matrix. - command.py: run_mcp_install signature with Path/Optional/Sequence shapes matching the Click handler that calls it. Also fix the patch target in the microsoft#938 regression test: test_mcp_registry_shorthand_no_overlays_persists_bare_string was patching apm_cli.commands.install.MCPIntegrator, but the --mcp path is now delegated to apm_cli.install.mcp.command.run_mcp_install, which imports MCPIntegrator from ...integration.mcp_integrator at module load. Patch the binding at the new lookup site so the test actually intercepts the integration call instead of relying on the defensive try/except in command.py to swallow real network failures.
…rerror-938 # Conflicts: # CHANGELOG.md
Description
apm install --mcp <registry-ref>without--transport,--url,--mcp-version,--registry, or a post---stdio command aborts with:The pure builder
_build_mcp_entryreturns a barestrfor thedocumented bare-string registry shorthand path (preserving the
mcp: [foo]apm.yml UX contract), but the F7 shell-metachar warningcallsite in
_run_mcp_installreadentry.get("command")unguarded.The exception fires before apm.yml is written, so the user is left
with no manifest update and only a workaround (
--transport stdio).Root cause
_build_mcp_entryreturnsstr | dictby design — bare strings forshorthand-with-no-overlays, dicts otherwise. Two later callsites in
_run_mcp_installalready guard withisinstance(entry, dict); theF7 warning callsite did not, and silently broke the bare-string
happy path.
Fix
Source the stdio command from
command_argv(the CLI input thatpopulates
entry["command"]in the dict-entry path) rather thanintrospecting the post-build entry. The two values are identical in
the stdio path and both yield
Noneelsewhere, so warning behaviouris preserved bit-for-bit while the str-vs-dict dispatch disappears
from this callsite. The other
isinstance-guarded callsites areleft untouched to keep the diff minimal.
Fixes #938
Type of change
Testing
Manual verification
apm install --mcp io.github.github/github-mcp-serverapm.yml... --transport stdio --dry-runapm install --mcp myfetch -- npx -y @modelcontextprotocol/server-fetchapm install --mcp evil --force -- 'echo;rm' argAutomated tests
test_mcp_registry_shorthand_no_overlays_persists_bare_stringin
tests/unit/test_install_command.pyreproduces the issue path andasserts
exit_code == 0plus bare-string serialization.'str' object has no attribute 'get'message and passes after the fix.test_install_command.py(100 tests) and the broader MCPbuilder / overlay / yml-writer suites (137 tests) remain green.