Skip to content

feat: warn on unknown keys dropped in MCPDependency.from_dict()#1674

Open
danielmeppiel wants to merge 2 commits into
mainfrom
autopilot/1670-mcp-warn-dropped-keys
Open

feat: warn on unknown keys dropped in MCPDependency.from_dict()#1674
danielmeppiel wants to merge 2 commits into
mainfrom
autopilot/1670-mcp-warn-dropped-keys

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

TL;DR

MCPDependency.from_dict() previously discarded unrecognised keys silently.
This PR adds a single aggregated [!] warning that names every dropped key and
the dependency context, so users are no longer left debugging silent data loss.
Unknown-key passthrough/preservation is out of scope and tracked separately.

Addresses #1670 (warn-on-dropped-keys; passthrough escape-hatch tracked separately, remains needs-design)


Problem (WHY)

"Unknown keys are silently ignored for forward compatibility."
-- from_dict() docstring, src/apm_cli/models/dependency/mcp.py

When a user supplies a field that APM's MCP model does not recognise -- for
example Claude Code's oauth block -- apm install completes with no output
and the field is gone from the generated .mcp.json. The user gets no signal
that their configuration was discarded, leading to authentication failures or
other silent misconfigurations that are very hard to debug.

Issue #1670 identifies two distinct problems: (1) no passthrough for
harness-specific MCP fields (needs-design, out of scope here), and (2) silent
drop with no warning (this PR). Fixing the silent drop first is the minimal,
safe intervention that already improves developer experience without requiring
any schema or API change.


Approach (WHAT)

Extend from_dict() with a key-diff check: compute the set of input keys that
are not in the known-key whitelist, and if any exist emit one aggregated
_rich_warning call naming all dropped keys and the dependency name.

flowchart TD
    A["from_dict(d)"] --> B{"'name' in d?"}
    B -->|No| C["raise ValueError"]
    B -->|Yes| D["unknown = keys(d) - KNOWN_DICT_KEYS"]
    D --> E{"unknown\nnon-empty?"}
    E -->|Yes| F["_rich_warning:\nunknown key(s) dropped: ..."]
    E -->|No| G["(no output)"]
    F --> H["construct MCPDependency\nfrom known keys only"]
    G --> H
    H --> I["validate() + return"]
Loading

Key design decisions:

  • One aggregated warning (not one per key) -- avoids log spam for configs with multiple extra fields.
  • symbol='warning' -- emits the [!] prefix via the repo's STATUS_SYMBOLS convention; ASCII-safe on all platforms.
  • No schema change, no new public API -- the model dataclass fields and to_dict() output are unchanged.
  • Known-key set (_KNOWN_DICT_KEYS) is a module-level frozenset so the diff is O(1) per call and the set is trivially auditable.

Implementation (HOW)

File Change
src/apm_cli/models/dependency/mcp.py Add _rich_warning import; introduce _KNOWN_DICT_KEYS frozenset; insert unknown-key check + warning at the top of from_dict(). Update docstring to reflect new behaviour.
tests/unit/test_mcp_from_dict_unknown_keys.py New file -- 13 acceptance tests across three classes: warning fires with correct content, no warning for known-only inputs, known-key parsing is unchanged.

Trade-offs

Trade-off Decision
Warn vs. raise Warn -- the original design intent was forward-compat; raising would be a breaking change.
One warning vs. one-per-key One aggregated -- avoids log spam for multi-key extras.
Module-level frozenset vs. inline Module-level -- auditable, reusable, O(1) diff.
Passthrough preservation Out of scope -- needs separate design; tracked in #1670 as needs-design.

Validation evidence

Acceptance tests (13 passing)
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictUnknownKeyWarning::test_unknown_key_triggers_warning PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictUnknownKeyWarning::test_unknown_key_warning_names_dropped_key PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictUnknownKeyWarning::test_multiple_unknown_keys_single_warning PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictUnknownKeyWarning::test_unknown_key_warning_names_dependency PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictUnknownKeyWarning::test_unknown_key_warning_is_ascii_only PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeysNoWarning::test_minimal_dict_no_warning PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeysNoWarning::test_all_known_keys_no_warning PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeysNoWarning::test_legacy_type_key_no_warning PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeysNoWarning::test_registry_resolved_server_no_warning PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeyParsingUnchanged::test_known_keys_parsed_correctly_with_unknown_present PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeyParsingUnchanged::test_unknown_key_not_stored_on_instance PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeyParsingUnchanged::test_to_dict_round_trip_unaffected PASSED
tests/unit/test_mcp_from_dict_unknown_keys.py::TestFromDictKnownKeyParsingUnchanged::test_missing_name_still_raises PASSED
13 passed in 0.49s
Lint (all gates green)
ruff check src/ tests/   -> All checks passed!
ruff format --check ...  -> 1225 files already formatted
pylint R0801             -> Your code has been rated at 10.00/10
lint-auth-signals.sh     -> [+] auth-signal lint clean

How to test

# 1. Run acceptance tests
uv run --extra dev pytest tests/unit/test_mcp_from_dict_unknown_keys.py -v

# 2. Run full MCP unit suite
uv run --extra dev pytest tests/unit/test_mcp_overlays.py tests/unit/test_build_mcp_entry.py -q

# 3. Confirm warning fires for an unknown key (manual spot-check)
uv run python -c "
from apm_cli.models.apm_package import MCPDependency
MCPDependency.from_dict({
    'name': 'slack', 'transport': 'http',
    'registry': False, 'url': 'https://mcp.slack.com/mcp',
    'oauth': {'clientId': 'abc', 'callbackPort': 3118},
})
"
# Expected output: [!] MCP dependency 'slack': unknown key(s) dropped: oauth

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

When from_dict() discards unrecognised keys it now emits a single
aggregated [!] warning naming every dropped key and the dependency
context, instead of silently ignoring them.

Known keys (name, transport/type, env, args, version, registry,
package, headers, tools, url, command) are unchanged.

Acceptance tests added in tests/unit/test_mcp_from_dict_unknown_keys.py.

Addresses #1670 (warn-on-dropped-keys; passthrough escape-hatch
tracked separately, remains needs-design)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 5, 2026 10:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves MCP dependency parsing ergonomics by making MCPDependency.from_dict() emit a single aggregated [!] warning when it drops unrecognized keys, instead of silently discarding them. This helps users diagnose forward-compat / harness-specific fields (e.g., oauth) that APM does not model.

Changes:

  • Add a known-key whitelist (_KNOWN_DICT_KEYS) and emit an aggregated warning for any extra keys in from_dict().
  • Update the from_dict() docstring to reflect the new warn-on-drop behavior.
  • Add unit tests covering warning emission, non-warning for known keys, and unchanged parsing/to_dict behavior.
Show a summary per file
File Description
src/apm_cli/models/dependency/mcp.py Adds _KNOWN_DICT_KEYS and an aggregated _rich_warning when unknown dict keys are present.
tests/unit/test_mcp_from_dict_unknown_keys.py Adds acceptance tests for warning behavior and ensures known-key parsing remains unchanged.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Comment thread src/apm_cli/models/dependency/mcp.py Outdated
Comment on lines +74 to +79
unknown = sorted(k for k in d if k not in _KNOWN_DICT_KEYS)
if unknown:
_rich_warning(
f"MCP dependency '{d['name']}': unknown key(s) dropped: {', '.join(unknown)}",
symbol="warning",
)
- Use str(k) in sorted() comprehension so integer/non-string YAML dict
  keys do not raise TypeError in the warning path
- Sanitize dep name and key strings via ascii()[1:-1] before interpolation
  to guarantee printable-ASCII CLI output on all platforms (cp1252-safe)
- Add two regression-trap tests: test_non_string_key_no_type_error and
  test_non_ascii_name_warning_is_ascii_only (15 tests total, all passing)
- Add CHANGELOG.md [Unreleased] Changed entry for the user-observable
  warn-on-drop behavior introduced in this PR

Folds: Copilot comment #3362016289 (LEGIT: TypeError + ASCII-unsafe)
       panel recommended follow-ups (sorted robustness, CHANGELOG, ascii guard)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

APM Review Panel: ship_with_followups

Warn-on-dropped-keys is a clean, well-scoped DX win; one defensive-coding gap (sorted() TypeError on non-string keys) and a missing CHANGELOG entry are the only recommended follow-ups before merge.

cc @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

This PR does exactly what it claims: surfaces a previously-silent data-loss path as a visible warning, scoped to #1670. Security is clean (markup=False blocks Rich injection, no credential surface touched, auth inactive). The strongest cross-panel signal was the sorted() TypeError risk on non-string dict keys: python-architect, cli-logging-expert, and test-coverage-expert all independently flagged it, and test-coverage confirmed the gap with a missing-test evidence block. Because the output contract promises a graceful warning, a crash in the very code path that issues that warning was a regression-trap inversion. The fix (a one-liner using str() coercion) plus ascii() sanitization for the name/key strings in the warning message, two companion regression-trap tests, and a CHANGELOG entry have all been folded into commit 955c972.

Nits (deduplication via DiagnosticCollector, CommandLogger lifecycle, _KNOWN_DICT_KEYS derivation from dataclass fields, actionable hint in warning text) are valid architectural observations for future PRs, not merge blockers. No panelist disagreed on severity classification, so no dissent to resolve. Scope guard respected: no panelist recommended passthrough/preservation of unknown keys.

Aligned with: pragmatic_as_npm -- warning on unrecognized keys mirrors npm's 'extraneous' warnings; users get signal without breakage. oss_community_driven -- this PR was filed to address a community-reported issue (#1670); shipping it with a clear CHANGELOG entry reinforces trust.

Growth signal. Silent failure to visible warning is tweetable: "APM now warns when harness-specific MCP fields are dropped -- no more silent config loss." Reinforces the AI-native package manager that respects your config narrative.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 1 Well-scoped warn-on-drop change; one defensive-coding gap (non-string dict keys) addressed in fold commit.
CLI Logging Expert 0 1 2 Warning message is well-formed and follows conventions; sorted() on non-string keys crash risk addressed.
DevX UX Expert 0 0 1 Warning message is clear, contextual, and consistent with existing _rich_warning patterns; no blocking UX issues.
Supply Chain Security 0 0 0 Warn-on-dropped-keys is safe: markup=False blocks Rich injection, key names are user-local, no credential surface touched.
OSS Growth Hacker 0 0 0 Strong contributor-experience win: silent data loss -> visible warning earns trust and prevents frustration-churn.
Doc Writer 0 1 1 No docs corpus drift; CHANGELOG entry added in fold commit.
Test Coverage Expert 0 1 0 13 new unit tests (now 15 after fold) cover the warning behavior well; non-string key gap addressed.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 3 follow-ups (FOLDED in commit 955c972)

  1. [Python Architect / CLI Logging / Test Coverage] Guard sorted() against non-string keys with str() coercion + ascii() sanitization + 2 companion regression-trap tests -- a TypeError crash in the warning path inverts the promise of graceful degradation. RESOLVED.
  2. [Doc Writer] Add CHANGELOG.md [Unreleased] Changed entry for user-observable dropped-key warning -- user-facing behavior change without release-note entry. RESOLVED.
  3. [Python Architect] Sanitize dep_label with ascii() before interpolation into warning string -- prevents terminal escape codes or non-ASCII from reaching warning output. RESOLVED.

Remaining deferred items:

  • DiagnosticCollector deduplication for repeated unknown-key warnings across N deps (cli-logging nit; cross-cutting refactor beyond this PR's scope)
  • Derive _KNOWN_DICT_KEYS from dataclass fields (python-architect nit; future optimization)
  • CommandLogger lifecycle refactor for model-layer direct output (cli-logging nit; existing precedent consistent)

Architecture

classDiagram
    direction LR
    class MCPDependency {
        <<Dataclass>>
        +name: str
        +transport: str | None
        +from_string(s) MCPDependency
        +from_dict(d) MCPDependency
        +validate(strict)
        +to_dict() dict
    }
    class _KNOWN_DICT_KEYS {
        <<ModuleConstant>>
        frozenset of 12 keys
    }
    class _rich_warning {
        <<IOBoundary>>
        +message: str
        +symbol: str
    }
    MCPDependency ..> _KNOWN_DICT_KEYS : membership test
    MCPDependency ..> _rich_warning : emits warning
    classDef touched fill:#fff3b0,stroke:#d47600
    class MCPDependency:::touched
    class _KNOWN_DICT_KEYS:::touched
Loading
flowchart TD
    A["CLI: apm install / apm run"] --> B["parse_manifest()"]
    B --> C["MCPDependency.from_dict(d)"]
    C --> D{"'name' in d?"}
    D -- No --> E["raise ValueError"]
    D -- Yes --> F["Compute unknown = sorted(str(k) for k in d ...)"]
    F --> G{"unknown non-empty?"}
    G -- Yes --> H["[I/O] _rich_warning(ascii-safe dropped keys)"]
    G -- No --> I["Skip warning"]
    H --> J["Extract transport via legacy alias"]
    I --> J
    J --> K["Construct MCPDependency instance"]
    K --> L{"registry is False?"}
    L -- Yes --> M["validate(strict=True)"]
    L -- No --> N["validate(strict=False)"]
    M --> O["Return instance"]
    N --> O
Loading

Reservations carried from strategic-alignment

Copilot signals reviewed

  • Comment #3362016289 (mcp.py:79): LEGIT -- sorted() TypeError on non-string keys; non-ASCII interpolation before validation. Folded in commit 955c972 (str() coercion + ascii() sanitization).

Lint evidence

ruff check src/ tests/   -> All checks passed!
ruff format --check ...  -> 1225 files already formatted
pylint R0801             -> Your code has been rated at 10.00/10
lint-auth-signals.sh     -> [+] auth-signal lint clean

CI evidence

CI was ALL GREEN on commit a2b8ed4 (pre-fold). Post-fold commit 955c972 is pending CI.

Mergeability

PR SHA CEO Stance Iter Folds Deferrals Copilot Rounds CI Mergeable Merge State Notes
#1674 955c972 ship_with_followups 1 3 3 1 pending MERGEABLE pending awaiting CI on fold commit

@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

APM Review Panel: ship_now (terminal pass - converged)

All recommended follow-ups folded, CI green, no outstanding blockers -- ship now.

cc @danielmeppiel @sergio-sisternes-epam -- terminal advisory pass: all recommended items folded, CI is green.

This terminal pass confirms convergence. The three recommended items from the first panel pass (sorted() TypeError guard, ascii() sanitization for warning output, and CHANGELOG entry) have all been folded into commit 955c972. Two new regression-trap tests were added covering the exact defect surfaces (non-string key coercion, non-ASCII warning sanitization), bringing the total to 15 passing tests. All 13 CI checks are green.

The 3 deferred items (DiagnosticCollector deduplication, _KNOWN_DICT_KEYS derivation from dataclass fields, CommandLogger lifecycle refactor) are correctly scoped out -- each is a cross-cutting refactor that would violate the PR's scope guard and carries no user-facing regression risk on this commit. No specialist disagreements remain; all panelists converged on ship-readiness after the fold commit landed.

Scope guard honored: passthrough/preservation of unknown keys remains OUT OF SCOPE, tracked separately in #1670 as needs-design. This PR correctly stays as 'Addresses #1670' (not 'Closes #1670').

Aligned with: pragmatic_as_npm -- defensive sorted() coercion and ascii-safe warnings prevent crash-on-edge-case scenarios that erode first-run confidence. oss_community_driven -- regression-trap tests lock the fix for future contributors; CHANGELOG entry communicates the change clearly.

Folded in this run

  • sorted() TypeError guard: sorted(str(k) for k in d ...) -- resolved in 955c972
  • ascii() sanitization: ascii(str(d['name']))[1:-1] and ascii(k)[1:-1] for keys -- resolved in 955c972
  • CHANGELOG.md [Unreleased] Changed entry for warn-on-drop behavior -- resolved in 955c972
  • Regression-trap test test_non_string_key_no_type_error -- resolved in 955c972
  • Regression-trap test test_non_ascii_name_warning_is_ascii_only -- resolved in 955c972

Deferred items

  • DiagnosticCollector deduplication for repeated warnings across N deps (cli-logging nit) -- scope boundary: cross-cutting refactor beyond this PR's warn-and-drop scope
  • Derive _KNOWN_DICT_KEYS from dataclasses.fields() (python-architect nit) -- scope boundary: future optimization unrelated to the warning behavior
  • CommandLogger lifecycle refactor for model-layer direct output (cli-logging nit) -- scope boundary: existing precedent consistent; cross-cutting refactor

Reservations carried from strategic-alignment

Copilot signals reviewed

  • Comment #3362016289 (mcp.py:79): LEGIT -- sorted() TypeError on non-string keys; non-ASCII interpolation before validation. Folded in commit 955c972 (str() coercion + ascii() sanitization + 2 regression-trap tests).

Lint evidence

ruff check src/ tests/   -> All checks passed!
ruff format --check ...  -> 1225 files already formatted
pylint R0801             -> Your code has been rated at 10.00/10
lint-auth-signals.sh     -> [+] auth-signal lint clean

CI evidence

All 13 checks GREEN on commit 955c972:
Coverage Combine (Linux): success | CodeQL: success | license/cla: success | gate: success | NOTICE Drift Check: success | Analyze (actions): success | Analyze (python): success | Spec conformance gate: success | APM Self-Check: success | PR Binary Smoke (Linux): success | Lint: success | Build & Test Shard 1 (Linux): success | Build & Test Shard 2 (Linux): success

Mergeability

PR SHA CEO Stance Iter Folds Deferrals Copilot Rounds CI Mergeable Merge State Notes
#1674 955c972 ship_now 1 5 3 1 green MERGEABLE BLOCKED awaiting required review approval

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants