Skip to content
Closed
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
6 changes: 3 additions & 3 deletions docs/src/content/docs/reference/baseline-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ the [policy schema](./policy-schema/).

### `config-consistency`

- **What it verifies.** That MCP server configs derived from both `dependencies.mcp` and `devDependencies.mcp` in `apm.yml` match the `mcp_configs` baseline stored in the lockfile.
- **Fails when.** A server's resolved config differs from the lockfile, a server is in the lockfile but not the manifest, or a server is in the manifest but not the lockfile.
- **Exception.** A lockfile-only server is expected when `mcp_config_provenance` identifies the sub-package that contributed it, so those transitive servers are not treated as orphans.
- **What it verifies.** That the complete current MCP declaration graph -- the root manifest, current local-package sources, and lock-bounded installed package manifests -- matches the `mcp_configs` baseline stored in the lockfile.
- **Fails when.** A server's resolved config differs from the lockfile, a source declaration was added, or a previously locked declaration was removed.
- **Ownership.** `mcp_config_provenance` identifies the package that contributed a lockfile-only server in the diagnostic; it does not exempt the removed declaration.
- **Remediation.** Run `apm install` to reconcile the MCP configuration.

### `content-integrity`
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/lockfile-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ local_deployed_file_hashes:
| `dependencies` | list | yes | Resolved APM packages. See [per-entry fields](#per-entry-fields). |
| `mcp_servers` | list of strings | no | Names of MCP servers managed as of the last install or update, including transitively contributed servers. |
| `mcp_configs` | map | no | `server_name -> resolved config dict` baseline used to detect MCP drift. |
| `mcp_config_provenance` | map | no | `server_name -> declaring package` for transitively contributed MCP servers. Used by `config-consistency` to distinguish transitive entries from orphans. |
| `mcp_config_provenance` | map | no | `server_name -> declaring package` for transitively contributed MCP servers. Used by `config-consistency` to identify the owner in drift diagnostics. |
| `lsp_servers` | list of strings | no | Names of LSP servers declared in the manifest as of the last install or update. |
| `lsp_configs` | map | no | `server_name -> resolved config dict` baseline used to detect LSP drift. |
| `local_deployed_files` | list | no | Files this project itself contributes (sources its own primitives). Reinstall reconciles these paths with the same target rules as per-dependency `deployed_files`. See [self entry](#self-entry). |
Expand Down
66 changes: 47 additions & 19 deletions src/apm_cli/policy/ci_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,44 +230,72 @@ def _check_config_consistency(
manifest: APMPackage,
lock: LockFile,
) -> CheckResult:
"""Verify MCP server configs match lockfile baseline."""
"""Verify the complete current MCP declaration graph matches the lockfile."""
from ..constants import APM_MODULES_DIR
from ..deps.path_anchoring import LocalResolutionError, resolve_local_dep_dir
from ..drift import detect_config_drift
from ..integration.mcp_integrator import MCPIntegrator
from ..models.apm_package import APMPackage

mcp_deps = manifest.get_all_mcp_dependencies()
details: list[str] = []
mcp_deps = list(manifest.get_all_mcp_dependencies())
for locked_dep in lock.get_package_dependencies():
if locked_dep.source == "local":
try:
package_dir = resolve_local_dep_dir(locked_dep, lock, manifest.package_path)
except LocalResolutionError as exc:
details.append(
f"{locked_dep.repo_url}: cannot resolve local package ({exc}) -- "
"fix the resolved_by chain or re-run 'apm install'"
)
continue
manifest_description = f"local package manifest at {package_dir / 'apm.yml'}"
else:
package_dir = locked_dep.to_dependency_ref().get_install_path(
manifest.package_path / APM_MODULES_DIR
)
manifest_description = f"manifest for installed package {locked_dep.repo_url}"

package_manifest = package_dir / "apm.yml"
if not package_manifest.exists():
continue
try:
package = APMPackage.from_apm_yml(package_manifest)
except (OSError, ValueError) as exc:
details.append(
f"{locked_dep.repo_url}: cannot parse {manifest_description} ({exc}) -- "
"fix the manifest or re-run 'apm install'"
)
continue
mcp_deps.extend(package.get_mcp_dependencies())

mcp_deps = MCPIntegrator.deduplicate(mcp_deps)
current_configs = MCPIntegrator.get_server_configs(mcp_deps)
stored_configs = lock.mcp_configs or {}

# No MCP deps at all -- nothing to check
if not current_configs and not stored_configs:
if not current_configs and not stored_configs and not details:
return CheckResult(
name="config-consistency",
passed=True,
message="No MCP configs to check",
)

details: list[str] = []

# Detect drift on servers that exist in both sets
drifted = detect_config_drift(current_configs, stored_configs)
for name in sorted(drifted):
details.append(f"{name}: config differs from lockfile baseline")

# Servers in lockfile but not in manifest (orphaned MCP).
# Transitively-contributed servers (declared by a local-path sub-package,
# recorded with provenance in mcp_config_provenance) are exempted: the root
# manifest cannot declare them, so flagging them creates an unfixable CI
# failure. This mirrors the resolved_by-based exemption in _check_no_orphans
# (#2081; MCP-side sibling of #1846/#1855).
# Compare names symmetrically. Provenance classifies ownership for the
# diagnostic; it never exempts a removed declaration from drift detection.
provenance = lock.mcp_config_provenance or {}
for name in sorted(stored_configs):
if name not in current_configs and name not in provenance:
details.append(f"{name}: in lockfile but not in manifest")

# Servers in manifest but not in lockfile (new, not installed)
for name in sorted(current_configs):
if name not in stored_configs:
details.append(f"{name}: in manifest but not in lockfile")
for name in sorted(stored_configs.keys() - current_configs.keys()):
owner = provenance.get(name)
owner_detail = f" (declared by {owner})" if owner else ""
details.append(f"{name}: in lockfile but not in current source{owner_detail}")

for name in sorted(current_configs.keys() - stored_configs.keys()):
details.append(f"{name}: in current source but not in lockfile")

if not details:
return CheckResult(
Expand Down
58 changes: 58 additions & 0 deletions tests/integration/test_transitive_mcp_audit_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,61 @@ def test_force_install_then_ci_audit_accepts_transitive_mcp(tmp_path: Path) -> N
check for check in payload["checks"] if check["name"] == "config-consistency"
)
assert config_check["passed"] is True, config_check


def test_ci_audit_rejects_removed_transitive_mcp_declaration(tmp_path: Path) -> None:
"""Removing an installed local package's MCP declaration must fail audit."""
project = _write_workspace(tmp_path)

install = _run(
project,
"install",
"--force",
"--trust-transitive-mcp",
"--target",
"copilot",
"--no-policy",
)
assert install.returncode == 0, (
f"install stdout:\n{install.stdout}\ninstall stderr:\n{install.stderr}"
)

control = _run(
project,
"audit",
"--ci",
"--no-policy",
"--no-fail-fast",
"-f",
"json",
)
assert control.returncode == 0, (
f"audit stdout:\n{control.stdout}\naudit stderr:\n{control.stderr}"
)

package_manifest = project / "packages" / "agent-config" / "apm.yml"
package_manifest.write_text(
"""name: agent-config
version: 1.0.0
""",
encoding="utf-8",
)

audit = _run(
project,
"audit",
"--ci",
"--no-policy",
"--no-fail-fast",
"-f",
"json",
)
assert audit.returncode == 1, f"audit stdout:\n{audit.stdout}\naudit stderr:\n{audit.stderr}"
payload = json.loads(audit.stdout)
config_check = next(
check for check in payload["checks"] if check["name"] == "config-consistency"
)
assert config_check["passed"] is False
assert config_check["details"] == [
"shadcn: in lockfile but not in current source (declared by agent-config)"
]
Loading
Loading