diff --git a/CHANGELOG.md b/CHANGELOG.md index dfac6064f..216663d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222) - Non-skill integrators (agent, instruction, prompt, command, hook script-copy) silently adopt byte-identical pre-existing files so a degraded `deployed_files=[]` lockfile no longer permanently blocks installs gated by `required-packages-deployed`. (#1313) - `apm audit` drift check now returns skip-with-info (`passed=True`) when the install cache is cold, instead of failing the audit; bare `apm audit` surfaces the skip reason on stderr so CI pipelines that have not yet run `apm install` are not incorrectly red-marked. (#1289) - `extends: org` now correctly layers `dependencies.require` and `dependencies.deny` from the parent policy when the child omits the `dependencies:` block entirely; `None` signals "no opinion" (transparent) while `[]` signals explicit override. (#1290) diff --git a/src/apm_cli/adapters/client/copilot.py b/src/apm_cli/adapters/client/copilot.py index 2751a30fe..ae527f7b5 100644 --- a/src/apm_cli/adapters/client/copilot.py +++ b/src/apm_cli/adapters/client/copilot.py @@ -93,6 +93,13 @@ def _has_env_placeholder(value): return bool(_COPILOT_ENV_RE.search(value)) +def _stringify_env_literal(value): + """Return MCP env literal values in the manifest ``map`` shape.""" + if isinstance(value, bool): + return str(value).lower() + return str(value) + + class CopilotClientAdapter(MCPClientAdapter): """Copilot CLI implementation of MCP client adapter. @@ -745,8 +752,10 @@ def _resolve_environment_variables(self, env_vars, env_overrides=None): for name, raw_value in env_vars.items(): if not name: continue + if raw_value is None: + continue if not isinstance(raw_value, str): - translated[name] = raw_value + translated[name] = _stringify_env_literal(raw_value) continue if _has_env_placeholder(raw_value): self._last_legacy_angle_vars.update(_extract_legacy_angle_vars(raw_value)) @@ -788,6 +797,19 @@ def _resolve_environment_variables(self, env_vars, env_overrides=None): self._last_env_placeholder_keys = set(placeholder_keys) return resolved + if isinstance(env_vars, dict): + resolved = {} + for name, value in env_vars.items(): + if not name: + continue + if isinstance(value, str): + resolved[name] = self._resolve_env_variable( + name, value, env_overrides=env_overrides + ) + elif value is not None: + resolved[name] = _stringify_env_literal(value) + return resolved + import os import sys diff --git a/tests/unit/test_claude_mcp.py b/tests/unit/test_claude_mcp.py index 86522e7f1..a9bc4ffcf 100644 --- a/tests/unit/test_claude_mcp.py +++ b/tests/unit/test_claude_mcp.py @@ -119,6 +119,36 @@ def test_update_config_tolerates_malformed_project_mcp_json(self): data = json.loads(self.mcp_path.read_text(encoding="utf-8")) self.assertIn("srv", data["mcpServers"]) + def test_configure_self_defined_stdio_preserves_env(self): + with patch.object(self.adapter, "registry_client") as mock_registry: + mock_registry.find_server_by_reference.return_value = { + "name": "env-demo", + "_raw_stdio": { + "command": "npx", + "args": ["-y", "example-mcp"], + "env": { + "DEMO_ENV": "demo-value", + "PORT": 3000, + "DEBUG": False, + "RATE": 0.5, + }, + }, + } + + ok = self.adapter.configure_mcp_server("env-demo") + + self.assertTrue(ok) + data = json.loads(self.mcp_path.read_text(encoding="utf-8")) + self.assertEqual( + data["mcpServers"]["env-demo"]["env"], + { + "DEMO_ENV": "demo-value", + "PORT": "3000", + "DEBUG": "false", + "RATE": "0.5", + }, + ) + class TestClaudeClientAdapterUser(unittest.TestCase): """User scope: ``~/.claude.json`` top-level ``mcpServers``.""" diff --git a/tests/unit/test_copilot_adapter.py b/tests/unit/test_copilot_adapter.py index df087efe6..83f2c1e32 100644 --- a/tests/unit/test_copilot_adapter.py +++ b/tests/unit/test_copilot_adapter.py @@ -557,6 +557,18 @@ def test_dict_shaped_env_block_does_not_silently_drop(self): ) self.assertEqual(set(result.keys()), {"FOO", "BAR"}) + def test_dict_shaped_env_block_stringifies_literals_and_omits_none(self): + adapter = CopilotClientAdapter() + result = adapter._resolve_environment_variables( + {"PORT": 3000, "DEBUG": False, "RATE": 0.5, "OPTIONAL": None}, + env_overrides=None, + ) + + self.assertEqual( + result, + {"PORT": "3000", "DEBUG": "false", "RATE": "0.5"}, + ) + class TestCopilotInstallRunSummary(unittest.TestCase): """Issue #1152: aggregated post-install diagnostics.