agent-shell-py 0.2.4__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/AGENTS.md +11 -1
  2. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/PKG-INFO +12 -4
  3. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/README.md +11 -3
  4. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/agent_parameter_comparison.md +7 -7
  5. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/skills/invoking-cli-agents/SKILL.md +2 -1
  6. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/_version.py +2 -2
  7. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/copilot_cli_adapter.py +1 -1
  8. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/cursor_adapter.py +168 -7
  9. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_copilot_cli_integration.py +13 -0
  10. agent_shell_py-0.2.5/tests/integration/test_cursor_mcp_integration.py +367 -0
  11. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_process_lifecycle.py +9 -5
  12. agent_shell_py-0.2.4/tests/integration/test_cursor_mcp_integration.py +0 -36
  13. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.github/workflows/build.yml +0 -0
  14. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.github/workflows/ci.yml +0 -0
  15. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.github/workflows/publish.yml +0 -0
  16. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.gitignore +0 -0
  17. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.python-version +0 -0
  18. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/LICENSE +0 -0
  19. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/assets/skill_banner.png +0 -0
  20. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/disabled_tools.md +0 -0
  21. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/info.md +0 -0
  22. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/total_token_count.md +0 -0
  23. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/pyproject.toml +0 -0
  24. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/skills/delegating-code-review/SKILL.md +0 -0
  25. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/skills/invoking-cli-agents/api-reference.md +0 -0
  26. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/__init__.py +0 -0
  27. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/__init__.py +0 -0
  28. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/agent_adapter_protocol.py +0 -0
  29. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/claude_code_adapter.py +0 -0
  30. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/codex_adapter.py +0 -0
  31. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/grok_adapter.py +0 -0
  32. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/health.py +0 -0
  33. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/model_discovery.py +0 -0
  34. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/opencode_adapter.py +0 -0
  35. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/outcome.py +0 -0
  36. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/pi_adapter.py +0 -0
  37. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/response.py +0 -0
  38. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/stderr_format.py +0 -0
  39. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/tool_denial.py +0 -0
  40. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/models/__init__.py +0 -0
  41. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/models/agent.py +0 -0
  42. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/process_cleanup.py +0 -0
  43. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/shell.py +0 -0
  44. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/__init__.py +0 -0
  45. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/conftest.py +0 -0
  46. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/__init__.py +0 -0
  47. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_claude_code_e2e.py +0 -0
  48. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_codex_e2e.py +0 -0
  49. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_copilot_cli_e2e.py +0 -0
  50. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_cursor_e2e.py +0 -0
  51. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_grok_e2e.py +0 -0
  52. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_health_check_e2e.py +0 -0
  53. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_model_discovery_e2e.py +0 -0
  54. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_opencode_e2e.py +0 -0
  55. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_pi_e2e.py +0 -0
  56. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/__init__.py +0 -0
  57. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_claude_code_integration.py +0 -0
  58. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_claude_code_mcp_integration.py +0 -0
  59. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_codex_integration.py +0 -0
  60. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_codex_mcp_integration.py +0 -0
  61. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_copilot_cli_mcp_integration.py +0 -0
  62. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_cursor_integration.py +0 -0
  63. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_grok_integration.py +0 -0
  64. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_grok_mcp_integration.py +0 -0
  65. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_health_check_integration.py +0 -0
  66. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_model_discovery_integration.py +0 -0
  67. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_opencode_integration.py +0 -0
  68. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_opencode_mcp_integration.py +0 -0
  69. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_pi_integration.py +0 -0
  70. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_pi_mcp_integration.py +0 -0
  71. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/__init__.py +0 -0
  72. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/adapter_matrix.py +0 -0
  73. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/codex_fixtures.py +0 -0
  74. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/copilot_fixtures.py +0 -0
  75. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/cursor_fixtures.py +0 -0
  76. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/fixtures.py +0 -0
  77. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/grok_fixtures.py +0 -0
  78. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/opencode_fixtures.py +0 -0
  79. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/pi_fixtures.py +0 -0
  80. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_adapter_transport.py +0 -0
  81. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cancel.py +0 -0
  82. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_cancel.py +0 -0
  83. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_execute.py +0 -0
  84. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_parse_event.py +0 -0
  85. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_warnings.py +0 -0
  86. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_cancel.py +0 -0
  87. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_execute.py +0 -0
  88. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_parse_event.py +0 -0
  89. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_stream.py +0 -0
  90. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_cancel.py +0 -0
  91. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_execute.py +0 -0
  92. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_parse_event.py +0 -0
  93. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_warnings.py +0 -0
  94. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_execute.py +0 -0
  95. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_execute_outcome.py +0 -0
  96. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_cancel.py +0 -0
  97. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_execute.py +0 -0
  98. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_parse_event.py +0 -0
  99. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_warnings.py +0 -0
  100. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_health_probe.py +0 -0
  101. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_mcp_server_spec.py +0 -0
  102. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_model_discovery.py +0 -0
  103. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_models.py +0 -0
  104. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_cancel.py +0 -0
  105. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_execute.py +0 -0
  106. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_parse_event.py +0 -0
  107. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_spawn.py +0 -0
  108. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_stream.py +0 -0
  109. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_parse_event.py +0 -0
  110. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_cancel.py +0 -0
  111. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_execute.py +0 -0
  112. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_parse_event.py +0 -0
  113. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_warnings.py +0 -0
  114. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_process_cleanup.py +0 -0
  115. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_process_group_registration.py +0 -0
  116. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_response_aggregation.py +0 -0
  117. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_shell.py +0 -0
  118. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_shell_cancellation.py +0 -0
  119. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_shell_mcp.py +0 -0
  120. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_stderr_format.py +0 -0
  121. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_stream.py +0 -0
  122. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_tool_denial.py +0 -0
  123. {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/uv.lock +0 -0
@@ -171,9 +171,19 @@ All adapters write to user-scope configuration:
171
171
  | OpenCode | direct JSON file write | `~/.config/opencode/opencode.json` |
172
172
  | Copilot CLI | direct JSON file write | `~/.copilot/mcp-config.json` |
173
173
  | Codex | `codex mcp add` subprocess | Codex config |
174
+ | Cursor | direct JSON file write | `~/.cursor/mcp.json` |
174
175
  | Grok | `grok mcp add --scope user` subprocess | `~/.grok/config.toml` (managed by CLI) |
175
176
 
176
- Adds are idempotent (overwrite existing entries with the same name). Removes warn rather than raise when the named server is not found. Claude Code listing reads the user-scope `mcpServers` entries from `~/.claude.json` directly, avoiding the health checks and human-readable output of `claude mcp list`. Grok listing reads user-scope `mcp_servers` entries from `~/.grok/config.toml` directly for the same reason. MCP is not implemented for Pi or Cursor — all three methods raise `NotImplementedError`. Pi manages capability via `pi install` extensions (which needs investigation before wiring up); Cursor's `mcp` subcommands are login/list/list-tools/enable/disable only (no add/remove — servers are declared in `.cursor/mcp.json`), and `mcp list` reports only `name: status`, not the transport an `MCPServerSpec` needs.
177
+ Adds are idempotent (update existing entries with the same name). Cursor preserves native fields
178
+ that `MCPServerSpec` cannot represent when an update keeps the same transport, and writes its file
179
+ atomically with user-only permissions. Removes warn rather than raise when the named server is not
180
+ found. Claude Code listing reads the user-scope `mcpServers`
181
+ entries from `~/.claude.json` directly, avoiding the health checks and human-readable output of
182
+ `claude mcp list`. Cursor manages user-scope MCP entries directly in `~/.cursor/mcp.json` because
183
+ its `mcp` subcommands have no add/remove commands. Grok listing reads user-scope `mcp_servers`
184
+ entries from `~/.grok/config.toml` directly for the same reason. Pi's MCP add/remove/list methods
185
+ raise `NotImplementedError`. Pi manages capability via `pi install` extensions, which needs
186
+ investigation before wiring up.
177
187
 
178
188
  ## Test Philosophy
179
189
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-shell-py
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: A lightweight abstraction for executing CLI coding agents headlessly
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -276,8 +276,9 @@ print(f"Session: {response.session_id}")
276
276
  > tools are auto-*rejected* but the run still completes. `allowed_tools`, `effort`, and
277
277
  > `disallowed_tools` are **ignored** — Cursor exposes no per-call tool policy or effort flag
278
278
  > (tool policy lives in `.cursor/cli.json`), so each emits a `UserWarning`. On a Free plan
279
- > only `model=None`/`"auto"` works. MCP servers are declared in `.cursor/mcp.json`; the
280
- > `add`/`remove`/`list` MCP methods raise `NotImplementedError`.
279
+ > only `model=None`/`"auto"` works. MCP add/remove/list are supported by directly managing
280
+ > the user-scope `~/.cursor/mcp.json` file because `cursor-agent mcp` has no add/remove
281
+ > subcommands.
281
282
 
282
283
  ### Grok
283
284
 
@@ -348,7 +349,14 @@ await shell.add_mcp_server(MCPServerSpec(
348
349
  ))
349
350
  ```
350
351
 
351
- `add_mcp_server` overwrites an existing server with the same name. `remove_mcp_server` warns rather than raises when the named server is not found. `list_mcp_servers()` works for Claude Code, OpenCode, Copilot CLI, Codex, and Grok. Claude Code reads user-scope entries directly from `~/.claude.json`, and Grok from `~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP is not supported for Pi or Cursor — neither CLI exposes an add/remove subcommand, so all three MCP methods raise `NotImplementedError`.
352
+ `add_mcp_server` adds or updates a server with the same name. Cursor preserves native fields
353
+ that `MCPServerSpec` cannot represent when an update keeps the same transport. The configuration
354
+ is written atomically with user-only permissions. `remove_mcp_server` warns rather than raises
355
+ when the named server is not found. `list_mcp_servers()` works for
356
+ Claude Code, OpenCode, Copilot CLI, Codex, Cursor, and Grok. Claude Code reads user-scope
357
+ entries directly from `~/.claude.json`, Cursor from `~/.cursor/mcp.json`, and Grok from
358
+ `~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP
359
+ is not supported for Pi; all three MCP methods raise `NotImplementedError`.
352
360
 
353
361
  ## Logging
354
362
 
@@ -267,8 +267,9 @@ print(f"Session: {response.session_id}")
267
267
  > tools are auto-*rejected* but the run still completes. `allowed_tools`, `effort`, and
268
268
  > `disallowed_tools` are **ignored** — Cursor exposes no per-call tool policy or effort flag
269
269
  > (tool policy lives in `.cursor/cli.json`), so each emits a `UserWarning`. On a Free plan
270
- > only `model=None`/`"auto"` works. MCP servers are declared in `.cursor/mcp.json`; the
271
- > `add`/`remove`/`list` MCP methods raise `NotImplementedError`.
270
+ > only `model=None`/`"auto"` works. MCP add/remove/list are supported by directly managing
271
+ > the user-scope `~/.cursor/mcp.json` file because `cursor-agent mcp` has no add/remove
272
+ > subcommands.
272
273
 
273
274
  ### Grok
274
275
 
@@ -339,7 +340,14 @@ await shell.add_mcp_server(MCPServerSpec(
339
340
  ))
340
341
  ```
341
342
 
342
- `add_mcp_server` overwrites an existing server with the same name. `remove_mcp_server` warns rather than raises when the named server is not found. `list_mcp_servers()` works for Claude Code, OpenCode, Copilot CLI, Codex, and Grok. Claude Code reads user-scope entries directly from `~/.claude.json`, and Grok from `~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP is not supported for Pi or Cursor — neither CLI exposes an add/remove subcommand, so all three MCP methods raise `NotImplementedError`.
343
+ `add_mcp_server` adds or updates a server with the same name. Cursor preserves native fields
344
+ that `MCPServerSpec` cannot represent when an update keeps the same transport. The configuration
345
+ is written atomically with user-only permissions. `remove_mcp_server` warns rather than raises
346
+ when the named server is not found. `list_mcp_servers()` works for
347
+ Claude Code, OpenCode, Copilot CLI, Codex, Cursor, and Grok. Claude Code reads user-scope
348
+ entries directly from `~/.claude.json`, Cursor from `~/.cursor/mcp.json`, and Grok from
349
+ `~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP
350
+ is not supported for Pi; all three MCP methods raise `NotImplementedError`.
343
351
 
344
352
  ## Logging
345
353
 
@@ -128,9 +128,9 @@ test checks all seven real discovery commands.
128
128
  - **Headless mode**: `-p` / `--prompt` for one-shot; `--acp` for Agent Client Protocol
129
129
  - **Model**: `--model <model>`; use `auto` to let Copilot choose
130
130
  - **Effort**: `--effort` / `--reasoning-effort` with choices
131
- `none|minimal|low|medium|high|xhigh|max`. AgentShell accepts effort values
132
- case-insensitively, validates them against these choices, and passes the normalized lowercase
133
- value to Copilot via `--effort`.
131
+ `none|minimal|low|medium|high|xhigh|max`. AgentShell treats `None` and `""` as omitted, so
132
+ Copilot uses its default. Explicit values, including `"none"`, are accepted case-insensitively,
133
+ validated against these choices, and passed to Copilot via `--effort` in lowercase.
134
134
  - **Allowed tools**: `--allow-tool`, `--deny-tool`, `--available-tools`,
135
135
  `--excluded-tools`, `--allow-all-tools`
136
136
  - **Auto-approve**: `--allow-all-tools`; `--allow-all` / `--yolo` also grant path and URL
@@ -193,10 +193,10 @@ test checks all seven real discovery commands.
193
193
  cursor-agent ACCEPTS an unknown id — `--resume=<never-seen-uuid>` starts a session under that
194
194
  id and echoes it back rather than failing, so id identity proves the flag was passed through
195
195
  and honoured, not that a prior transcript was replayed (measured 2026-07-26)
196
- - **MCP**: `cursor-agent mcp` = login/list/list-tools/enable/disable only (no add/remove);
197
- servers declared in `.cursor/mcp.json`. All three adapter MCP methods raise
198
- `NotImplementedError` — including `list_mcp_servers`, because `mcp list` emits only
199
- `name: status` and cannot rebuild an `MCPServerSpec`
196
+ - **MCP**: `cursor-agent mcp` = login/list/list-tools/enable/disable only (no add/remove).
197
+ The adapter manages user-scope servers directly in `~/.cursor/mcp.json`, which provides the
198
+ transport details that `mcp list` omits. Writes are atomic and user-only; same-transport
199
+ updates preserve Cursor-native fields that `MCPServerSpec` cannot represent.
200
200
  - **Usage**: the terminal `result` event carries `usage.outputTokens` (undocumented but real)
201
201
  and `duration_ms`; there is no cost field, so `cost` is `0.0`
202
202
 
@@ -316,7 +316,8 @@ succeeded = saw_ok and error is None # absent result => succeeded stays
316
316
  **not** run your prompt, so use the stream-based check above when you care about a specific
317
317
  call's outcome.
318
318
  - **MCP server management** — `add_mcp_server`, `remove_mcp_server`, `list_mcp_servers` manage
319
- the underlying CLI's MCP config (Pi and Cursor raise `NotImplementedError`).
319
+ user-scope MCP configuration. Pi raises `NotImplementedError`; Cursor is managed directly
320
+ through `~/.cursor/mcp.json` because `cursor-agent mcp` has no add/remove subcommands.
320
321
 
321
322
  See [api-reference.md](api-reference.md) for their full signatures and the `MCPServerSpec` model.
322
323
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.4'
22
- __version_tuple__ = version_tuple = (0, 2, 4)
21
+ __version__ = version = '0.2.5'
22
+ __version_tuple__ = version_tuple = (0, 2, 5)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -48,7 +48,7 @@ _COPILOT_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
48
48
 
49
49
 
50
50
  def _normalize_effort(effort: str | None) -> str | None:
51
- if effort is None:
51
+ if effort is None or effort == "":
52
52
  return None
53
53
 
54
54
  normalized = effort.lower() if isinstance(effort, str) else None
@@ -4,12 +4,15 @@ import json
4
4
  import logging
5
5
  import os
6
6
  import warnings
7
+ from pathlib import Path
8
+ from tempfile import NamedTemporaryFile
7
9
  from typing import AsyncIterator
8
10
 
9
11
  from agent_shell.models.agent import (
10
12
  AgentResponse,
11
13
  HealthCheckResult,
12
14
  MCPServerSpec,
15
+ MCPServerType,
13
16
  StreamEvent,
14
17
  )
15
18
  from agent_shell.process_cleanup import (
@@ -324,15 +327,173 @@ class CursorAdapter:
324
327
  models.append(model.strip())
325
328
  return models
326
329
 
330
+ def _mcp_config_path(self) -> Path:
331
+ return Path(os.path.expanduser("~/.cursor/mcp.json"))
332
+
333
+ def _read_mcp_config(self) -> dict:
334
+ path = self._mcp_config_path()
335
+ if not path.exists():
336
+ return {}
337
+ return json.loads(path.read_text())
338
+
339
+ def _write_mcp_config(self, config: dict) -> None:
340
+ path = self._mcp_config_path()
341
+ path.parent.mkdir(parents=True, exist_ok=True)
342
+ temporary_path: Path | None = None
343
+
344
+ try:
345
+ with NamedTemporaryFile(
346
+ mode="w",
347
+ encoding="utf-8",
348
+ dir=path.parent,
349
+ prefix=f".{path.name}.",
350
+ delete=False,
351
+ ) as temporary_file:
352
+ temporary_path = Path(temporary_file.name)
353
+ temporary_file.write(json.dumps(config, indent=2) + "\n")
354
+ temporary_file.flush()
355
+ os.fsync(temporary_file.fileno())
356
+
357
+ os.replace(temporary_path, path)
358
+ temporary_path = None
359
+ finally:
360
+ if temporary_path is not None:
361
+ temporary_path.unlink(missing_ok=True)
362
+
363
+ @staticmethod
364
+ def _mcp_servers(config: dict) -> dict:
365
+ if not isinstance(config, dict):
366
+ raise ValueError("Cursor mcp.json root must be a JSON object")
367
+
368
+ servers = config.get("mcpServers", {})
369
+ if not isinstance(servers, dict):
370
+ raise ValueError("Cursor 'mcpServers' must be a JSON object")
371
+
372
+ config["mcpServers"] = servers
373
+ return servers
374
+
375
+ @staticmethod
376
+ def _mcp_entry_type(entry: dict) -> MCPServerType | None:
377
+ entry_type = entry.get("type")
378
+ if entry_type == "stdio":
379
+ return MCPServerType.STDIO
380
+ if entry_type in {"http", "sse"}:
381
+ return MCPServerType.HTTP
382
+ if entry_type is not None:
383
+ return None
384
+ if entry.get("command"):
385
+ return MCPServerType.STDIO
386
+ if entry.get("url"):
387
+ return MCPServerType.HTTP
388
+ return None
389
+
390
+ @staticmethod
391
+ def _string_list(value: object, field_name: str) -> list[str]:
392
+ if value is None:
393
+ return []
394
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
395
+ raise TypeError(f"{field_name} must be a list of strings")
396
+ return list(value)
397
+
398
+ @staticmethod
399
+ def _string_mapping(value: object, field_name: str) -> dict[str, str]:
400
+ if value is None:
401
+ return {}
402
+ if not isinstance(value, dict) or not all(
403
+ isinstance(key, str) and isinstance(item, str)
404
+ for key, item in value.items()
405
+ ):
406
+ raise TypeError(f"{field_name} must be an object with string values")
407
+ return dict(value)
408
+
327
409
  async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None:
328
- # Cursor's `mcp` subcommands are login/list/list-tools/enable/disable ONLY — there is
329
- # no add/remove. Servers are declared in .cursor/mcp.json, and `mcp list` reports only
330
- # `name: status` (no transport/command/url), so an MCPServerSpec cannot be faithfully
331
- # round-tripped either. Fail loud rather than silently no-op.
332
- raise NotImplementedError("add_mcp_server is not supported for Cursor")
410
+ config = self._read_mcp_config()
411
+ servers = self._mcp_servers(config)
412
+
413
+ if mcp_server.type == MCPServerType.STDIO:
414
+ entry = {
415
+ "command": mcp_server.command,
416
+ "args": list(mcp_server.args),
417
+ "env": dict(mcp_server.env),
418
+ }
419
+ else:
420
+ entry = {
421
+ "url": mcp_server.url,
422
+ "headers": dict(mcp_server.headers),
423
+ }
424
+
425
+ existing = servers.get(mcp_server.name)
426
+ if (
427
+ isinstance(existing, dict)
428
+ and self._mcp_entry_type(existing) == mcp_server.type
429
+ ):
430
+ entry = {**existing, **entry}
431
+
432
+ servers[mcp_server.name] = entry
433
+ self._write_mcp_config(config)
333
434
 
334
435
  async def remove_mcp_server(self, mcp_server_name: str) -> None:
335
- raise NotImplementedError("remove_mcp_server is not supported for Cursor")
436
+ config = self._read_mcp_config()
437
+ servers = self._mcp_servers(config)
438
+ if mcp_server_name not in servers:
439
+ warnings.warn(
440
+ f"MCP server '{mcp_server_name}' not found in Cursor config",
441
+ UserWarning,
442
+ stacklevel=2,
443
+ )
444
+ return
445
+
446
+ del servers[mcp_server_name]
447
+ self._write_mcp_config(config)
336
448
 
337
449
  async def list_mcp_servers(self) -> list[MCPServerSpec]:
338
- raise NotImplementedError("list_mcp_servers is not supported for Cursor")
450
+ config = self._read_mcp_config()
451
+ try:
452
+ servers = self._mcp_servers(config)
453
+ except ValueError as error:
454
+ warnings.warn(str(error), UserWarning, stacklevel=2)
455
+ return []
456
+
457
+ result: list[MCPServerSpec] = []
458
+ for name, entry in servers.items():
459
+ if not isinstance(entry, dict):
460
+ warnings.warn(
461
+ f"Skipping malformed MCP entry '{name}': expected object, "
462
+ f"got {type(entry).__name__}",
463
+ UserWarning,
464
+ stacklevel=2,
465
+ )
466
+ continue
467
+
468
+ try:
469
+ entry_type = self._mcp_entry_type(entry)
470
+ if entry_type == MCPServerType.STDIO:
471
+ result.append(MCPServerSpec(
472
+ name=name,
473
+ type=MCPServerType.STDIO,
474
+ command=entry.get("command"),
475
+ args=self._string_list(entry.get("args"), "args"),
476
+ env=self._string_mapping(entry.get("env"), "env"),
477
+ ))
478
+ elif entry_type == MCPServerType.HTTP:
479
+ result.append(MCPServerSpec(
480
+ name=name,
481
+ type=MCPServerType.HTTP,
482
+ url=entry.get("url"),
483
+ headers=self._string_mapping(entry.get("headers"), "headers"),
484
+ ))
485
+ else:
486
+ warnings.warn(
487
+ f"Skipping malformed MCP entry '{name}': "
488
+ "unsupported or missing transport",
489
+ UserWarning,
490
+ stacklevel=2,
491
+ )
492
+ except (TypeError, ValueError) as error:
493
+ warnings.warn(
494
+ f"Skipping malformed MCP entry '{name}': {error}",
495
+ UserWarning,
496
+ stacklevel=2,
497
+ )
498
+
499
+ return result
@@ -224,6 +224,19 @@ class TestCommandConstructionIntegration:
224
224
  assert "--effort" in cmd_args
225
225
  assert cmd_args[cmd_args.index("--effort") + 1] == "high"
226
226
 
227
+ async def test_execute_omits_effort_flag_for_empty_string(self):
228
+ # Arrange
229
+ shell = AgentShell(agent_type=AgentType.COPILOT_CLI)
230
+ ndjson = [MESSAGE_DELTA_EVENT, RESULT_EVENT_SUCCESS]
231
+ mock_process = _make_mock_process(ndjson)
232
+
233
+ # Act
234
+ with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec:
235
+ await shell.execute(cwd="/tmp", prompt="test", effort="")
236
+
237
+ # Assert
238
+ assert "--effort" not in mock_exec.call_args[0]
239
+
227
240
  async def test_prompt_is_second_argument(self):
228
241
  # Arrange
229
242
  shell = AgentShell(agent_type=AgentType.COPILOT_CLI)
@@ -0,0 +1,367 @@
1
+ import json
2
+ import stat
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from agent_shell.shell import AgentShell
8
+ from agent_shell.models.agent import AgentType, MCPServerSpec, MCPServerType
9
+
10
+
11
+ @pytest.fixture
12
+ def isolated_home(tmp_path, monkeypatch):
13
+ monkeypatch.setenv("HOME", str(tmp_path))
14
+ return tmp_path
15
+
16
+
17
+ def _read_config(home: Path) -> dict:
18
+ return json.loads((home / ".cursor" / "mcp.json").read_text())
19
+
20
+
21
+ class TestCursorMcp:
22
+ async def test_adds_stdio_server_to_user_config(self, isolated_home):
23
+ # Arrange
24
+ shell = AgentShell(agent_type=AgentType.CURSOR)
25
+ spec = MCPServerSpec(
26
+ name="forgetful",
27
+ type=MCPServerType.STDIO,
28
+ command="uvx",
29
+ args=["forgetful-ai"],
30
+ env={"FORGETFUL_API_KEY": "secret"},
31
+ )
32
+
33
+ # Act
34
+ await shell.add_mcp_server(spec)
35
+
36
+ # Assert
37
+ config_path = isolated_home / ".cursor" / "mcp.json"
38
+ assert _read_config(isolated_home) == {
39
+ "mcpServers": {
40
+ "forgetful": {
41
+ "command": "uvx",
42
+ "args": ["forgetful-ai"],
43
+ "env": {"FORGETFUL_API_KEY": "secret"},
44
+ }
45
+ }
46
+ }
47
+ assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
48
+
49
+ async def test_failed_atomic_replace_preserves_existing_config(
50
+ self,
51
+ isolated_home,
52
+ monkeypatch,
53
+ ):
54
+ # Arrange
55
+ config_path = isolated_home / ".cursor" / "mcp.json"
56
+ config_path.parent.mkdir(parents=True)
57
+ original = {"mcpServers": {"existing": {"command": "existing-command"}}}
58
+ config_path.write_text(json.dumps(original))
59
+ shell = AgentShell(agent_type=AgentType.CURSOR)
60
+
61
+ def fail_replace(source, destination):
62
+ raise OSError("simulated replace failure")
63
+
64
+ monkeypatch.setattr("agent_shell.adapters.cursor_adapter.os.replace", fail_replace)
65
+
66
+ # Act / Assert
67
+ with pytest.raises(OSError, match="simulated replace failure"):
68
+ await shell.add_mcp_server(MCPServerSpec(
69
+ name="new",
70
+ type=MCPServerType.STDIO,
71
+ command="new-command",
72
+ ))
73
+
74
+ assert _read_config(isolated_home) == original
75
+ assert list(config_path.parent.iterdir()) == [config_path]
76
+
77
+ async def test_add_rejects_malformed_server_collection_without_writing(
78
+ self,
79
+ isolated_home,
80
+ ):
81
+ # Arrange
82
+ config_path = isolated_home / ".cursor" / "mcp.json"
83
+ config_path.parent.mkdir(parents=True)
84
+ original = {"mcpServers": ["broken"]}
85
+ config_path.write_text(json.dumps(original))
86
+ shell = AgentShell(agent_type=AgentType.CURSOR)
87
+
88
+ # Act / Assert
89
+ with pytest.raises(ValueError, match="mcpServers"):
90
+ await shell.add_mcp_server(MCPServerSpec(
91
+ name="new",
92
+ type=MCPServerType.STDIO,
93
+ command="new-command",
94
+ ))
95
+
96
+ assert _read_config(isolated_home) == original
97
+
98
+ async def test_same_transport_update_preserves_cursor_only_fields(self, isolated_home):
99
+ # Arrange
100
+ config_path = isolated_home / ".cursor" / "mcp.json"
101
+ config_path.parent.mkdir(parents=True)
102
+ config_path.write_text(json.dumps({
103
+ "mcpServers": {
104
+ "local": {
105
+ "type": "stdio",
106
+ "command": "old-command",
107
+ "args": ["old-arg"],
108
+ "env": {"OLD": "value"},
109
+ "envFile": "~/.cursor/mcp.env",
110
+ }
111
+ }
112
+ }))
113
+ shell = AgentShell(agent_type=AgentType.CURSOR)
114
+ replacement = MCPServerSpec(
115
+ name="local",
116
+ type=MCPServerType.STDIO,
117
+ command="new-command",
118
+ args=["new-arg"],
119
+ env={"NEW": "value"},
120
+ )
121
+
122
+ # Act
123
+ await shell.add_mcp_server(replacement)
124
+
125
+ # Assert
126
+ assert _read_config(isolated_home)["mcpServers"]["local"] == {
127
+ "type": "stdio",
128
+ "command": "new-command",
129
+ "args": ["new-arg"],
130
+ "env": {"NEW": "value"},
131
+ "envFile": "~/.cursor/mcp.env",
132
+ }
133
+
134
+ async def test_remote_update_preserves_auth_and_sse_type(self, isolated_home):
135
+ # Arrange
136
+ config_path = isolated_home / ".cursor" / "mcp.json"
137
+ config_path.parent.mkdir(parents=True)
138
+ config_path.write_text(json.dumps({
139
+ "mcpServers": {
140
+ "remote": {
141
+ "type": "sse",
142
+ "url": "https://old.example.com/sse",
143
+ "headers": {"OLD": "value"},
144
+ "auth": {"CLIENT_ID": "client-id"},
145
+ }
146
+ }
147
+ }))
148
+ shell = AgentShell(agent_type=AgentType.CURSOR)
149
+ replacement = MCPServerSpec(
150
+ name="remote",
151
+ type=MCPServerType.HTTP,
152
+ url="https://new.example.com/sse",
153
+ headers={"NEW": "value"},
154
+ )
155
+
156
+ # Act
157
+ await shell.add_mcp_server(replacement)
158
+
159
+ # Assert
160
+ assert _read_config(isolated_home)["mcpServers"]["remote"] == {
161
+ "type": "sse",
162
+ "url": "https://new.example.com/sse",
163
+ "headers": {"NEW": "value"},
164
+ "auth": {"CLIENT_ID": "client-id"},
165
+ }
166
+
167
+ async def test_transport_change_drops_old_transport_fields(self, isolated_home):
168
+ # Arrange
169
+ config_path = isolated_home / ".cursor" / "mcp.json"
170
+ config_path.parent.mkdir(parents=True)
171
+ config_path.write_text(json.dumps({
172
+ "mcpServers": {
173
+ "changing": {
174
+ "type": "stdio",
175
+ "command": "old-command",
176
+ "envFile": "~/.cursor/mcp.env",
177
+ }
178
+ }
179
+ }))
180
+ shell = AgentShell(agent_type=AgentType.CURSOR)
181
+
182
+ # Act
183
+ await shell.add_mcp_server(MCPServerSpec(
184
+ name="changing",
185
+ type=MCPServerType.HTTP,
186
+ url="https://example.com/mcp",
187
+ ))
188
+
189
+ # Assert
190
+ assert _read_config(isolated_home)["mcpServers"]["changing"] == {
191
+ "url": "https://example.com/mcp",
192
+ "headers": {},
193
+ }
194
+
195
+ async def test_adds_http_server_without_replacing_existing_config(self, isolated_home):
196
+ # Arrange
197
+ config_path = isolated_home / ".cursor" / "mcp.json"
198
+ config_path.parent.mkdir(parents=True)
199
+ config_path.write_text(json.dumps({
200
+ "mcpServers": {"existing": {"command": "existing-command"}},
201
+ "unrelated": {"preserve": True},
202
+ }))
203
+ shell = AgentShell(agent_type=AgentType.CURSOR)
204
+ spec = MCPServerSpec(
205
+ name="remote",
206
+ type=MCPServerType.HTTP,
207
+ url="https://example.com/mcp",
208
+ headers={"Authorization": "Bearer secret"},
209
+ )
210
+
211
+ # Act
212
+ await shell.add_mcp_server(spec)
213
+
214
+ # Assert
215
+ assert _read_config(isolated_home) == {
216
+ "mcpServers": {
217
+ "existing": {"command": "existing-command"},
218
+ "remote": {
219
+ "url": "https://example.com/mcp",
220
+ "headers": {"Authorization": "Bearer secret"},
221
+ },
222
+ },
223
+ "unrelated": {"preserve": True},
224
+ }
225
+
226
+ async def test_removes_server_without_replacing_existing_config(self, isolated_home):
227
+ # Arrange
228
+ config_path = isolated_home / ".cursor" / "mcp.json"
229
+ config_path.parent.mkdir(parents=True)
230
+ config_path.write_text(json.dumps({
231
+ "mcpServers": {
232
+ "forgetful": {"command": "uvx"},
233
+ "existing": {"command": "existing-command"},
234
+ },
235
+ "unrelated": {"preserve": True},
236
+ }))
237
+ shell = AgentShell(agent_type=AgentType.CURSOR)
238
+
239
+ # Act
240
+ await shell.remove_mcp_server("forgetful")
241
+
242
+ # Assert
243
+ assert _read_config(isolated_home) == {
244
+ "mcpServers": {"existing": {"command": "existing-command"}},
245
+ "unrelated": {"preserve": True},
246
+ }
247
+
248
+ async def test_remove_warns_when_server_is_missing(self, isolated_home):
249
+ # Arrange
250
+ shell = AgentShell(agent_type=AgentType.CURSOR)
251
+
252
+ # Act / Assert
253
+ with pytest.warns(UserWarning, match="not found"):
254
+ await shell.remove_mcp_server("missing")
255
+
256
+ assert not (isolated_home / ".cursor" / "mcp.json").exists()
257
+
258
+ async def test_lists_stdio_and_http_servers_from_user_config(self, isolated_home):
259
+ # Arrange
260
+ config_path = isolated_home / ".cursor" / "mcp.json"
261
+ config_path.parent.mkdir(parents=True)
262
+ config_path.write_text(json.dumps({
263
+ "mcpServers": {
264
+ "forgetful": {
265
+ "command": "uvx",
266
+ "args": ["forgetful-ai"],
267
+ "env": {"K": "V"},
268
+ },
269
+ "remote": {
270
+ "url": "https://example.com/mcp",
271
+ "headers": {"Authorization": "Bearer secret"},
272
+ },
273
+ }
274
+ }))
275
+ shell = AgentShell(agent_type=AgentType.CURSOR)
276
+
277
+ # Act
278
+ servers = await shell.list_mcp_servers()
279
+
280
+ # Assert
281
+ assert servers == [
282
+ MCPServerSpec(
283
+ name="forgetful",
284
+ type=MCPServerType.STDIO,
285
+ command="uvx",
286
+ args=["forgetful-ai"],
287
+ env={"K": "V"},
288
+ ),
289
+ MCPServerSpec(
290
+ name="remote",
291
+ type=MCPServerType.HTTP,
292
+ url="https://example.com/mcp",
293
+ headers={"Authorization": "Bearer secret"},
294
+ ),
295
+ ]
296
+
297
+ async def test_list_honours_explicit_transport_type(self, isolated_home):
298
+ # Arrange
299
+ config_path = isolated_home / ".cursor" / "mcp.json"
300
+ config_path.parent.mkdir(parents=True)
301
+ config_path.write_text(json.dumps({
302
+ "mcpServers": {
303
+ "legacy-sse": {
304
+ "type": "sse",
305
+ "url": "https://example.com/sse",
306
+ "command": "must-not-win",
307
+ }
308
+ }
309
+ }))
310
+ shell = AgentShell(agent_type=AgentType.CURSOR)
311
+
312
+ # Act
313
+ servers = await shell.list_mcp_servers()
314
+
315
+ # Assert
316
+ assert servers == [MCPServerSpec(
317
+ name="legacy-sse",
318
+ type=MCPServerType.HTTP,
319
+ url="https://example.com/sse",
320
+ )]
321
+
322
+ async def test_list_skips_malformed_entries_with_warnings(self, isolated_home):
323
+ # Arrange
324
+ config_path = isolated_home / ".cursor" / "mcp.json"
325
+ config_path.parent.mkdir(parents=True)
326
+ config_path.write_text(json.dumps({
327
+ "mcpServers": {
328
+ "not-an-object": "broken",
329
+ "missing-transport": {"args": ["orphaned"]},
330
+ "invalid-args": {"command": "uvx", "args": 42},
331
+ "string-args": {"command": "uvx", "args": "--port"},
332
+ "invalid-env": {"command": "uvx", "env": {"PORT": 42}},
333
+ "good": {"command": "good-command"},
334
+ }
335
+ }))
336
+ shell = AgentShell(agent_type=AgentType.CURSOR)
337
+
338
+ # Act
339
+ with pytest.warns(UserWarning) as captured:
340
+ servers = await shell.list_mcp_servers()
341
+
342
+ # Assert
343
+ messages = [str(warning.message) for warning in captured]
344
+ assert any("not-an-object" in message for message in messages)
345
+ assert any("missing-transport" in message for message in messages)
346
+ assert any("invalid-args" in message for message in messages)
347
+ assert any("string-args" in message for message in messages)
348
+ assert any("invalid-env" in message for message in messages)
349
+ assert servers == [MCPServerSpec(
350
+ name="good",
351
+ type=MCPServerType.STDIO,
352
+ command="good-command",
353
+ )]
354
+
355
+ async def test_list_warns_when_mcp_servers_is_not_an_object(self, isolated_home):
356
+ # Arrange
357
+ config_path = isolated_home / ".cursor" / "mcp.json"
358
+ config_path.parent.mkdir(parents=True)
359
+ config_path.write_text(json.dumps({"mcpServers": ["broken"]}))
360
+ shell = AgentShell(agent_type=AgentType.CURSOR)
361
+
362
+ # Act
363
+ with pytest.warns(UserWarning, match="mcpServers"):
364
+ servers = await shell.list_mcp_servers()
365
+
366
+ # Assert
367
+ assert servers == []
@@ -244,11 +244,15 @@ async def test_guardian_does_not_inherit_the_owners_file_descriptors(tmp_path):
244
244
  guardian = process_cleanup._guardians[process]
245
245
 
246
246
  try:
247
- # Act
248
- guardian_fds = {
249
- fd: os.readlink(f"/proc/{guardian.pid}/fd/{fd}")
250
- for fd in os.listdir(f"/proc/{guardian.pid}/fd")
251
- }
247
+ # Act — /proc is a live view: an fd can close after listdir() but before readlink().
248
+ # That normal Linux race made the tag build flaky with FileNotFoundError. Ignore only
249
+ # entries that vanished; any descriptor still open is captured and asserted below.
250
+ guardian_fds: dict[str, str] = {}
251
+ for fd in os.listdir(f"/proc/{guardian.pid}/fd"):
252
+ try:
253
+ guardian_fds[fd] = os.readlink(f"/proc/{guardian.pid}/fd/{fd}")
254
+ except FileNotFoundError:
255
+ continue
252
256
 
253
257
  # Assert — a guardian must not keep an owner's output pipe, log, or native FD open.
254
258
  assert guardian_fds["1"] == "/dev/null"
@@ -1,36 +0,0 @@
1
- import pytest
2
-
3
- from agent_shell.shell import AgentShell
4
- from agent_shell.models.agent import AgentType, MCPServerSpec, MCPServerType
5
-
6
-
7
- class TestCursorMcpNotImplemented:
8
- # Cursor's `mcp` subcommands are login/list/list-tools/enable/disable ONLY — no add/remove.
9
- # Servers are declared in .cursor/mcp.json, and `mcp list` reports only `name: status`
10
- # (not the transport/command/url MCPServerSpec needs), so no method can be faithfully
11
- # supported. They must fail loud, not silently no-op.
12
- async def test_add_mcp_server_raises(self):
13
- # Arrange
14
- shell = AgentShell(agent_type=AgentType.CURSOR)
15
- spec = MCPServerSpec(name="forgetful", type=MCPServerType.STDIO, command="uvx",
16
- args=["forgetful-ai"])
17
-
18
- # Act / Assert
19
- with pytest.raises(NotImplementedError):
20
- await shell.add_mcp_server(spec)
21
-
22
- async def test_remove_mcp_server_raises(self):
23
- # Arrange
24
- shell = AgentShell(agent_type=AgentType.CURSOR)
25
-
26
- # Act / Assert
27
- with pytest.raises(NotImplementedError):
28
- await shell.remove_mcp_server("forgetful")
29
-
30
- async def test_list_mcp_servers_raises(self):
31
- # Arrange
32
- shell = AgentShell(agent_type=AgentType.CURSOR)
33
-
34
- # Act / Assert
35
- with pytest.raises(NotImplementedError):
36
- await shell.list_mcp_servers()
File without changes
File without changes