From 89b23eebc4ea693bd7e2d9a59707d118f0458b0b Mon Sep 17 00:00:00 2001 From: Ted Swinyar Date: Mon, 15 Jun 2026 19:25:31 -0700 Subject: [PATCH] fix(mcp): launch bundled cao-mcp-server without a per-launch network fetch The bundled agent profiles configured the orchestration MCP server as `uvx --from git+https://github.com/awslabs/cli-agent-orchestrator.git@main cao-mcp-server`, which cold-clones and builds the whole package from GitHub on every agent launch. The project's own e2e cache-warmup fixture documents this as ~20s on a cold cache, which exceeds Codex's default 10s MCP startup timeout, so the agent starts WITHOUT its orchestration tools (handoff / assign / send_message) and silently no-ops -- the supervisor looks healthy but cannot delegate. It also pinned the MCP server to @main regardless of the installed CAO version, so the orchestration client could drift out of sync with the API server it talks to. cao-mcp-server is a console script installed alongside cao / cao-server, so the running CAO install already has a matching one. Point the bundled profiles (and examples/docs) at it, and add utils/mcp_resolution.py to turn the bare command into a PATH-independent invocation, since the agent subprocess's PATH may not contain the script dir (unactivated venv, devcontainer, pip --prefix): 1. the cao-mcp-server next to the running interpreter (exact env match), else 2. cao-mcp-server resolved on PATH, else 3. -m cli_agent_orchestrator.mcp_server.server (no script needed). Runtime providers (Claude Code, Codex, Kimi, Copilot) rebuild the launch config each time and prefer the interpreter sibling. Install-time providers (Kiro, OpenCode) and the Antigravity/Cursor providers write the command to a config file they read at a later launch, so they resolve with persisted=True and prefer the stable PATH launcher (e.g. ~/.local/bin/...) -- which survives `uv tool upgrade`, whereas the versioned venv path would not. Copilot's previously-inline resolution now uses the shared helper. Also: guard the resolver against an empty sys.executable; delete the e2e uvx cache-warmup fixture (no network fetch to warm now); update the example profiles; and add resolver and bundled-profile tests. --- CHANGELOG.md | 2 + docs/agent-profile.md | 7 +- examples/assign/README.md | 7 +- examples/assign/analysis_supervisor.md | 7 +- examples/assign/data_analyst.md | 7 +- examples/assign/report_generator.md | 7 +- .../cloudwatch-logs/cloudwatch-logs-agent.md | 7 +- .../dynamodb-delete/dynamodb-delete-agent.md | 7 +- .../dynamodb-query/dynamodb-query-agent.md | 7 +- .../aws/sqs-dlq-check/sqs-dlq-check-agent.md | 7 +- examples/aws/sqs-monitor/sqs-monitor-agent.md | 7 +- examples/aws/sqs-send/sqs-send-agent.md | 7 +- .../aws/stepfunction/stepfunction-agent.md | 7 +- examples/codex-basic/codex_developer.md | 7 +- examples/codex-basic/codex_documenter.md | 7 +- examples/codex-basic/codex_reviewer.md | 7 +- examples/cross-provider/README.md | 7 +- .../cross_provider_supervisor.md | 7 +- .../data_analyst_claude_code.md | 7 +- examples/cross-provider/data_analyst_codex.md | 7 +- .../data_analyst_copilot_cli.md | 7 +- .../cross-provider/data_analyst_kimi_cli.md | 7 +- .../cross-provider/data_analyst_kiro_cli.md | 7 +- .../cross-provider/report_generator_codex.md | 7 +- examples/orchestration/dev-kimi.md | 7 +- examples/orchestration/dev-opus.md | 7 +- examples/orchestration/dev-sonnet.md | 7 +- examples/orchestration/reviewer-opus.md | 7 +- .../agent_store/code_supervisor.md | 7 +- .../agent_store/developer.md | 7 +- .../agent_store/memory_manager.md | 7 +- .../agent_store/reviewer.md | 7 +- .../agent_store/workflow_scout.md | 7 +- .../providers/antigravity_cli.py | 18 +- .../providers/claude_code.py | 5 + src/cli_agent_orchestrator/providers/codex.py | 6 +- .../providers/copilot_cli.py | 32 +-- .../providers/cursor_cli.py | 4 + .../providers/kimi_cli.py | 5 + .../services/install_service.py | 35 ++- .../utils/mcp_resolution.py | 142 ++++++++++ .../utils/opencode_config.py | 10 +- test/e2e/conftest.py | 27 -- test/e2e/script_runner/conftest.py | 18 +- test/providers/test_antigravity_cli_unit.py | 39 +++ test/providers/test_claude_code_unit.py | 25 ++ test/providers/test_codex_provider_unit.py | 27 ++ test/providers/test_copilot_cli_unit.py | 13 + test/providers/test_cursor_cli_unit.py | 32 +++ test/providers/test_kimi_cli_unit.py | 25 ++ test/services/test_install_service.py | 67 +++++ test/utils/test_bundled_profiles.py | 58 ++++ test/utils/test_mcp_resolution.py | 249 ++++++++++++++++++ test/utils/test_opencode_config.py | 39 ++- 54 files changed, 856 insertions(+), 246 deletions(-) create mode 100644 src/cli_agent_orchestrator/utils/mcp_resolution.py create mode 100644 test/utils/test_bundled_profiles.py create mode 100644 test/utils/test_mcp_resolution.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6830ea511..80e6c56d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- mcp: bundled agent profiles now launch the installed `cao-mcp-server` console script instead of re-fetching the whole package from GitHub via `uvx --from git+…@main` on every agent launch — the cold fetch (~20s) exceeded some providers' MCP startup timeout (Codex: 10s), so agents came up without their orchestration tools (`handoff`/`assign`/`send_message`) and silently could not delegate; a shared resolver (`utils/mcp_resolution.py`) rewrites the bare command to a PATH-independent invocation (interpreter-sibling → PATH → `python -m`), used by all providers, and a regression test keeps new profiles from reintroducing the network fetch (#403) + - claude_code: a backgrounded task ("✻ Waiting for N dynamic workflow(s) to finish") no longer reads as COMPLETED — the wait line has no spinner ellipsis (invisible to every PROCESSING check) while the printed response + idle ❯ box look like a finished turn, and it even matched the lenient completion pattern; both the raw-buffer and pyte screen paths now report PROCESSING until a newer response/completion summary appears, so dashboards and wait_until_terminal_status no longer see a mid-run terminal as done (#392) - fifo: non-blocking FIFO reader loop and event-loop-safe session teardown — reader threads can no longer be stranded in a blocking FIFO `open()` by a stop/reopen race, and `DELETE /sessions` runs teardown in a worker thread, so repeated create/delete cycles can no longer wedge cao-server (#382) diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 612acdd08..c93a56b5a 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -65,11 +65,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DEVELOPER AGENT diff --git a/examples/assign/README.md b/examples/assign/README.md index 2085f7297..de41b8a8c 100644 --- a/examples/assign/README.md +++ b/examples/assign/README.md @@ -60,11 +60,8 @@ description: Your agent description mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- ``` diff --git a/examples/assign/analysis_supervisor.md b/examples/assign/analysis_supervisor.md index 5c12361e9..b035ce3ec 100644 --- a/examples/assign/analysis_supervisor.md +++ b/examples/assign/analysis_supervisor.md @@ -5,11 +5,8 @@ role: supervisor # @cao-mcp-server, fs_read, fs_list. For fine-grained control, mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # ANALYSIS SUPERVISOR AGENT diff --git a/examples/assign/data_analyst.md b/examples/assign/data_analyst.md index c6f737ad1..469c174be 100644 --- a/examples/assign/data_analyst.md +++ b/examples/assign/data_analyst.md @@ -5,11 +5,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DATA ANALYST AGENT diff --git a/examples/assign/report_generator.md b/examples/assign/report_generator.md index 24bae7197..072d095f8 100644 --- a/examples/assign/report_generator.md +++ b/examples/assign/report_generator.md @@ -5,11 +5,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # REPORT GENERATOR AGENT diff --git a/examples/aws/cloudwatch-logs/cloudwatch-logs-agent.md b/examples/aws/cloudwatch-logs/cloudwatch-logs-agent.md index 71934693d..49d4038f3 100644 --- a/examples/aws/cloudwatch-logs/cloudwatch-logs-agent.md +++ b/examples/aws/cloudwatch-logs/cloudwatch-logs-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CloudWatch Logs Agent diff --git a/examples/aws/dynamodb-delete/dynamodb-delete-agent.md b/examples/aws/dynamodb-delete/dynamodb-delete-agent.md index bf670e445..c78b35e08 100644 --- a/examples/aws/dynamodb-delete/dynamodb-delete-agent.md +++ b/examples/aws/dynamodb-delete/dynamodb-delete-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DynamoDB Delete Agent diff --git a/examples/aws/dynamodb-query/dynamodb-query-agent.md b/examples/aws/dynamodb-query/dynamodb-query-agent.md index 5ea1c9d70..be0cf874d 100644 --- a/examples/aws/dynamodb-query/dynamodb-query-agent.md +++ b/examples/aws/dynamodb-query/dynamodb-query-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DynamoDB Query Agent diff --git a/examples/aws/sqs-dlq-check/sqs-dlq-check-agent.md b/examples/aws/sqs-dlq-check/sqs-dlq-check-agent.md index ae6434f67..d85799e7a 100644 --- a/examples/aws/sqs-dlq-check/sqs-dlq-check-agent.md +++ b/examples/aws/sqs-dlq-check/sqs-dlq-check-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # SQS DLQ Check Agent diff --git a/examples/aws/sqs-monitor/sqs-monitor-agent.md b/examples/aws/sqs-monitor/sqs-monitor-agent.md index fd4daa2d3..839d23761 100644 --- a/examples/aws/sqs-monitor/sqs-monitor-agent.md +++ b/examples/aws/sqs-monitor/sqs-monitor-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # SQS Monitor Agent diff --git a/examples/aws/sqs-send/sqs-send-agent.md b/examples/aws/sqs-send/sqs-send-agent.md index 790800ecf..716402535 100644 --- a/examples/aws/sqs-send/sqs-send-agent.md +++ b/examples/aws/sqs-send/sqs-send-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # SQS Send Agent diff --git a/examples/aws/stepfunction/stepfunction-agent.md b/examples/aws/stepfunction/stepfunction-agent.md index 947d2766e..9924cec5e 100644 --- a/examples/aws/stepfunction/stepfunction-agent.md +++ b/examples/aws/stepfunction/stepfunction-agent.md @@ -7,11 +7,8 @@ allowedTools: mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # Step Functions Agent diff --git a/examples/codex-basic/codex_developer.md b/examples/codex-basic/codex_developer.md index 93b01d484..f6b8c50b9 100644 --- a/examples/codex-basic/codex_developer.md +++ b/examples/codex-basic/codex_developer.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] skills: [python, javascript, typescript, debugging, testing, api-development, documentation] experience_level: senior diff --git a/examples/codex-basic/codex_documenter.md b/examples/codex-basic/codex_documenter.md index ad0bf6ee4..d26e80406 100644 --- a/examples/codex-basic/codex_documenter.md +++ b/examples/codex-basic/codex_documenter.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] skills: [technical-writing, api-documentation, user-guides, tutorials, readme-creation, developer-docs] experience_level: senior diff --git a/examples/codex-basic/codex_reviewer.md b/examples/codex-basic/codex_reviewer.md index b673dc341..7ebdc490c 100644 --- a/examples/codex-basic/codex_reviewer.md +++ b/examples/codex-basic/codex_reviewer.md @@ -6,11 +6,8 @@ role: reviewer # @builtin, fs_read, fs_list, @cao-mcp-server. For fine-grained mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] skills: [code-review, security-audit, performance-analysis, best-practices, testing-strategies, documentation-review] experience_level: senior diff --git a/examples/cross-provider/README.md b/examples/cross-provider/README.md index 05296f973..0c94cac14 100644 --- a/examples/cross-provider/README.md +++ b/examples/cross-provider/README.md @@ -137,11 +137,8 @@ provider: codex mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- ``` diff --git a/examples/cross-provider/cross_provider_supervisor.md b/examples/cross-provider/cross_provider_supervisor.md index d55ca35f3..3693dc898 100644 --- a/examples/cross-provider/cross_provider_supervisor.md +++ b/examples/cross-provider/cross_provider_supervisor.md @@ -5,11 +5,8 @@ role: supervisor # @cao-mcp-server, fs_read, fs_list. For fine-grained control, mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CROSS-PROVIDER SUPERVISOR AGENT diff --git a/examples/cross-provider/data_analyst_claude_code.md b/examples/cross-provider/data_analyst_claude_code.md index fe049127d..9f29bc38e 100644 --- a/examples/cross-provider/data_analyst_claude_code.md +++ b/examples/cross-provider/data_analyst_claude_code.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DATA ANALYST AGENT diff --git a/examples/cross-provider/data_analyst_codex.md b/examples/cross-provider/data_analyst_codex.md index ad19168b6..f534f8875 100644 --- a/examples/cross-provider/data_analyst_codex.md +++ b/examples/cross-provider/data_analyst_codex.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DATA ANALYST AGENT diff --git a/examples/cross-provider/data_analyst_copilot_cli.md b/examples/cross-provider/data_analyst_copilot_cli.md index e2e8c5172..d4aa1ca8d 100644 --- a/examples/cross-provider/data_analyst_copilot_cli.md +++ b/examples/cross-provider/data_analyst_copilot_cli.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DATA ANALYST AGENT diff --git a/examples/cross-provider/data_analyst_kimi_cli.md b/examples/cross-provider/data_analyst_kimi_cli.md index 3ca43d8e6..2acf47d8c 100644 --- a/examples/cross-provider/data_analyst_kimi_cli.md +++ b/examples/cross-provider/data_analyst_kimi_cli.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DATA ANALYST AGENT diff --git a/examples/cross-provider/data_analyst_kiro_cli.md b/examples/cross-provider/data_analyst_kiro_cli.md index 621842162..bdb7e3341 100644 --- a/examples/cross-provider/data_analyst_kiro_cli.md +++ b/examples/cross-provider/data_analyst_kiro_cli.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DATA ANALYST AGENT diff --git a/examples/cross-provider/report_generator_codex.md b/examples/cross-provider/report_generator_codex.md index 5c160f11d..24b0b90dd 100644 --- a/examples/cross-provider/report_generator_codex.md +++ b/examples/cross-provider/report_generator_codex.md @@ -6,11 +6,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # REPORT GENERATOR AGENT diff --git a/examples/orchestration/dev-kimi.md b/examples/orchestration/dev-kimi.md index 288da277c..feef6399a 100644 --- a/examples/orchestration/dev-kimi.md +++ b/examples/orchestration/dev-kimi.md @@ -6,11 +6,8 @@ role: developer mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CODING WORKER (Kimi) diff --git a/examples/orchestration/dev-opus.md b/examples/orchestration/dev-opus.md index 3250d544b..15596c850 100644 --- a/examples/orchestration/dev-opus.md +++ b/examples/orchestration/dev-opus.md @@ -7,11 +7,8 @@ role: developer mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CODING WORKER (Opus) diff --git a/examples/orchestration/dev-sonnet.md b/examples/orchestration/dev-sonnet.md index 9fb6a7c2e..6a6225de8 100644 --- a/examples/orchestration/dev-sonnet.md +++ b/examples/orchestration/dev-sonnet.md @@ -7,11 +7,8 @@ role: developer mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CODING WORKER (Sonnet) diff --git a/examples/orchestration/reviewer-opus.md b/examples/orchestration/reviewer-opus.md index 5ac075327..80226fc0e 100644 --- a/examples/orchestration/reviewer-opus.md +++ b/examples/orchestration/reviewer-opus.md @@ -7,11 +7,8 @@ role: reviewer mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CODE REVIEWER (Opus) diff --git a/src/cli_agent_orchestrator/agent_store/code_supervisor.md b/src/cli_agent_orchestrator/agent_store/code_supervisor.md index f92487c0c..03ea6d714 100644 --- a/src/cli_agent_orchestrator/agent_store/code_supervisor.md +++ b/src/cli_agent_orchestrator/agent_store/code_supervisor.md @@ -5,11 +5,8 @@ role: supervisor # @cao-mcp-server, fs_read, fs_list. For fine-grained control, mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CODING SUPERVISOR AGENT diff --git a/src/cli_agent_orchestrator/agent_store/developer.md b/src/cli_agent_orchestrator/agent_store/developer.md index 10c801dc7..9a374f2db 100644 --- a/src/cli_agent_orchestrator/agent_store/developer.md +++ b/src/cli_agent_orchestrator/agent_store/developer.md @@ -5,11 +5,8 @@ role: developer # @builtin, fs_*, execute_bash, @cao-mcp-server. For fine-grain mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # DEVELOPER AGENT diff --git a/src/cli_agent_orchestrator/agent_store/memory_manager.md b/src/cli_agent_orchestrator/agent_store/memory_manager.md index 103f733fe..ae6e0c3b6 100644 --- a/src/cli_agent_orchestrator/agent_store/memory_manager.md +++ b/src/cli_agent_orchestrator/agent_store/memory_manager.md @@ -5,11 +5,8 @@ role: supervisor mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CONTEXT-MANAGER AGENT diff --git a/src/cli_agent_orchestrator/agent_store/reviewer.md b/src/cli_agent_orchestrator/agent_store/reviewer.md index 1f566379f..25a3328fb 100644 --- a/src/cli_agent_orchestrator/agent_store/reviewer.md +++ b/src/cli_agent_orchestrator/agent_store/reviewer.md @@ -5,11 +5,8 @@ role: reviewer # @builtin, fs_read, fs_list, @cao-mcp-server. For fine-grained mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # CODE REVIEWER AGENT diff --git a/src/cli_agent_orchestrator/agent_store/workflow_scout.md b/src/cli_agent_orchestrator/agent_store/workflow_scout.md index e625b6bb6..bed054e39 100644 --- a/src/cli_agent_orchestrator/agent_store/workflow_scout.md +++ b/src/cli_agent_orchestrator/agent_store/workflow_scout.md @@ -5,11 +5,8 @@ role: workflow_scout # @builtin, fs_read, execute_bash, @cao-mcp-server mcpServers: cao-mcp-server: type: stdio - command: uvx - args: - - "--from" - - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main" - - "cao-mcp-server" + command: cao-mcp-server + args: [] --- # WORKFLOW SCOUT AGENT diff --git a/src/cli_agent_orchestrator/providers/antigravity_cli.py b/src/cli_agent_orchestrator/providers/antigravity_cli.py index 5eab1ea4c..4d59f866e 100644 --- a/src/cli_agent_orchestrator/providers/antigravity_cli.py +++ b/src/cli_agent_orchestrator/providers/antigravity_cli.py @@ -54,6 +54,7 @@ from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.services.settings_service import get_server_settings from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile +from cli_agent_orchestrator.utils.mcp_resolution import resolve_cao_mcp_command from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status from cli_agent_orchestrator.utils.text import strip_terminal_escapes @@ -337,9 +338,16 @@ def _register_mcp_servers(self, mcp_servers: dict) -> None: cfg = dict(server_config) else: cfg = server_config.model_dump(exclude_none=True) - entry = { - "command": cfg.get("command", ""), - "args": cfg.get("args", []), + # Resolve the bundled cao-mcp-server console script to a + # PATH-independent invocation. persisted=True: this command is + # written to mcp_config.json and read by agy at later launches, + # so prefer the stable PATH launcher over the versioned venv path. + command, args = resolve_cao_mcp_command( + cfg.get("command", ""), cfg.get("args", []) or [], persisted=True + ) + entry: dict = { + "command": command, + "args": args, } env = dict(cfg.get("env", {})) env["CAO_TERMINAL_ID"] = self.terminal_id @@ -444,8 +452,8 @@ async def initialize(self) -> bool: # untrusted cwd). Unanswered it blocks init — the picker never reads IDLE. self._handle_startup_dialog() - # agy startup + first MCP connection (cao-mcp-server is fetched via uvx - # from git on first use) + the -i acknowledgment can take a while. + # agy startup + first MCP connection + the -i acknowledgment can take + # a while. if not await wait_until_status( self.terminal_id, {TerminalStatus.IDLE, TerminalStatus.COMPLETED}, diff --git a/src/cli_agent_orchestrator/providers/claude_code.py b/src/cli_agent_orchestrator/providers/claude_code.py index 4edfb58e2..1c45164a8 100644 --- a/src/cli_agent_orchestrator/providers/claude_code.py +++ b/src/cli_agent_orchestrator/providers/claude_code.py @@ -15,6 +15,7 @@ from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.services.settings_service import get_server_settings from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile +from cli_agent_orchestrator.utils.mcp_resolution import resolve_mcp_server_config from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status from cli_agent_orchestrator.utils.text import strip_terminal_escapes @@ -251,6 +252,10 @@ def _build_claude_command(self) -> str: else: mcp_config[server_name] = server_config.model_dump(exclude_none=True) + # Resolve the bundled cao-mcp-server console script to a + # PATH-independent invocation. + mcp_config[server_name] = resolve_mcp_server_config(mcp_config[server_name]) + env = mcp_config[server_name].get("env", {}) if "CAO_TERMINAL_ID" not in env: env["CAO_TERMINAL_ID"] = self.terminal_id diff --git a/src/cli_agent_orchestrator/providers/codex.py b/src/cli_agent_orchestrator/providers/codex.py index efe1aaa26..820eafbdc 100644 --- a/src/cli_agent_orchestrator/providers/codex.py +++ b/src/cli_agent_orchestrator/providers/codex.py @@ -12,6 +12,7 @@ from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.services.settings_service import get_server_settings from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile +from cli_agent_orchestrator.utils.mcp_resolution import resolve_mcp_server_config from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status from cli_agent_orchestrator.utils.text import strip_terminal_escapes @@ -277,9 +278,12 @@ def _build_codex_command(self) -> str: for server_name, server_config in profile.mcpServers.items(): prefix = f"mcp_servers.{server_name}" if isinstance(server_config, dict): - cfg = server_config + cfg = dict(server_config) else: cfg = server_config.model_dump(exclude_none=True) + # Resolve the bundled cao-mcp-server console script to a + # PATH-independent invocation. + cfg = resolve_mcp_server_config(cfg) if "command" in cfg: command_parts.extend(["-c", f'{prefix}.command="{cfg["command"]}"']) if "args" in cfg: diff --git a/src/cli_agent_orchestrator/providers/copilot_cli.py b/src/cli_agent_orchestrator/providers/copilot_cli.py index a23ace651..08846e7cc 100644 --- a/src/cli_agent_orchestrator/providers/copilot_cli.py +++ b/src/cli_agent_orchestrator/providers/copilot_cli.py @@ -8,9 +8,7 @@ import os import re import shlex -import shutil import subprocess -import sys import time from pathlib import Path from typing import Optional @@ -21,6 +19,7 @@ from cli_agent_orchestrator.models.terminal import TerminalStatus from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.services.settings_service import get_server_settings +from cli_agent_orchestrator.utils.mcp_resolution import resolve_cao_mcp_command from cli_agent_orchestrator.utils.terminal import wait_for_shell logger = logging.getLogger(__name__) @@ -178,25 +177,16 @@ def _command(self) -> str: return shlex.join(command_parts) def _build_runtime_mcp_config(self) -> str: - merged_servers: dict = {} - venv_script = Path(sys.executable).with_name("cao-mcp-server") - found_script = shutil.which("cao-mcp-server") - mcp_args: list[str] - if venv_script.exists(): - mcp_command = str(venv_script) - mcp_args = [] - elif found_script: - mcp_command = found_script - mcp_args = [] - else: - mcp_command = sys.executable - mcp_args = ["-m", "cli_agent_orchestrator.mcp_server.server"] - - merged_servers["cao-mcp-server"] = { - "command": mcp_command, - "args": mcp_args, - "disabled": False, - "env": {"CAO_TERMINAL_ID": self.terminal_id}, + # Resolve the bundled cao-mcp-server console script to a PATH-independent + # invocation via the shared resolver. + mcp_command, mcp_args = resolve_cao_mcp_command("cao-mcp-server", []) + merged_servers: dict = { + "cao-mcp-server": { + "command": mcp_command, + "args": mcp_args, + "disabled": False, + "env": {"CAO_TERMINAL_ID": self.terminal_id}, + } } return json.dumps({"mcpServers": merged_servers}, ensure_ascii=False) diff --git a/src/cli_agent_orchestrator/providers/cursor_cli.py b/src/cli_agent_orchestrator/providers/cursor_cli.py index 1d3a1c0e0..6e8b22ad4 100644 --- a/src/cli_agent_orchestrator/providers/cursor_cli.py +++ b/src/cli_agent_orchestrator/providers/cursor_cli.py @@ -75,6 +75,7 @@ from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.services.settings_service import get_server_settings from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile +from cli_agent_orchestrator.utils.mcp_resolution import resolve_mcp_server_config from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status from cli_agent_orchestrator.utils.text import strip_terminal_escapes @@ -504,6 +505,9 @@ def _write_plugin_dir(self, mcp_servers) -> str: servers[server_name] = dict(server_config) else: servers[server_name] = server_config.model_dump(exclude_none=True) + # Resolve the bundled cao-mcp-server console script to a + # PATH-independent invocation. + servers[server_name] = resolve_mcp_server_config(servers[server_name]) env = servers[server_name].get("env", {}) if "CAO_TERMINAL_ID" not in env: env["CAO_TERMINAL_ID"] = self.terminal_id diff --git a/src/cli_agent_orchestrator/providers/kimi_cli.py b/src/cli_agent_orchestrator/providers/kimi_cli.py index 349371957..65e1405b4 100644 --- a/src/cli_agent_orchestrator/providers/kimi_cli.py +++ b/src/cli_agent_orchestrator/providers/kimi_cli.py @@ -41,6 +41,7 @@ from cli_agent_orchestrator.providers.base import BaseProvider from cli_agent_orchestrator.services.settings_service import get_server_settings from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile +from cli_agent_orchestrator.utils.mcp_resolution import resolve_mcp_server_config from cli_agent_orchestrator.utils.terminal import wait_for_shell, wait_until_status from cli_agent_orchestrator.utils.text import strip_terminal_escapes @@ -338,6 +339,10 @@ def _build_kimi_command(self) -> str: else: mcp_config[server_name] = server_config.model_dump(exclude_none=True) + # Resolve the bundled cao-mcp-server console script to a + # PATH-independent invocation. + mcp_config[server_name] = resolve_mcp_server_config(mcp_config[server_name]) + # Forward CAO_TERMINAL_ID so MCP servers (e.g. cao-mcp-server) # can identify the current terminal for handoff/assign operations. # Kimi CLI does not automatically forward parent shell env vars diff --git a/src/cli_agent_orchestrator/services/install_service.py b/src/cli_agent_orchestrator/services/install_service.py index 5ed9d8eb4..a2e2ba4b8 100644 --- a/src/cli_agent_orchestrator/services/install_service.py +++ b/src/cli_agent_orchestrator/services/install_service.py @@ -27,6 +27,7 @@ parse_agent_profile_text, ) from cli_agent_orchestrator.utils.env import resolve_env_vars, set_env_var +from cli_agent_orchestrator.utils.mcp_resolution import resolve_mcp_server_config from cli_agent_orchestrator.utils.opencode_config import ( ensure_skills_symlink, remove_agent_tools, @@ -76,9 +77,13 @@ def _inject_kiro_mcp_timeout( cao-mcp-server entry that does not already specify one. kiro reads the per-server ``timeout`` field (milliseconds) as its tool-call - timeout. We only touch entries whose args reference ``cao-mcp-server`` so a - user's other MCP servers keep their own (or kiro's default) timeout. An - explicit operator-set ``timeout`` is never overwritten. + timeout. We only touch entries whose name, command, or args reference the + bundled orchestration server so a user's other MCP servers keep their own + (or kiro's default) timeout. An explicit operator-set ``timeout`` is never + overwritten. The command/args checks cover every form the entry can take: + the bare console script, a resolved absolute path, the module entrypoint + (`` -m cli_agent_orchestrator.mcp_server.server``), and the legacy + ``uvx --from git+... cao-mcp-server`` form. """ if not mcp_servers: return mcp_servers @@ -88,9 +93,16 @@ def _inject_kiro_mcp_timeout( if not isinstance(cfg, dict): result[name] = cfg continue + command = cfg.get("command") args = cfg.get("args") or [] - is_cao = name == "cao-mcp-server" or any( - isinstance(a, str) and "cao-mcp-server" in a for a in args + is_cao = ( + name == "cao-mcp-server" + or (isinstance(command, str) and "cao-mcp-server" in command) + or any( + isinstance(a, str) + and ("cao-mcp-server" in a or a == "cli_agent_orchestrator.mcp_server.server") + for a in args + ) ) if is_cao and "timeout" not in cfg: cfg = {**cfg, "timeout": _KIRO_MCP_TOOL_TIMEOUT_MS} @@ -272,6 +284,19 @@ def install_agent( resolved_content = resolve_env_vars(raw_content) profile = parse_agent_profile_text(resolved_content, agent_name) + # Resolve the bundled cao-mcp-server console script to a PATH-independent + # invocation before materializing provider configs. The + # configs Kiro/Q write to disk are consumed verbatim by those CLIs, so + # resolution must happen here rather than at launch time. persisted=True + # prefers the stable PATH launcher (e.g. ~/.local/bin/cao-mcp-server) + # over the versioned venv-internal path, so a later `uv tool upgrade` + # does not leave the written config pointing at a relocated binary. + if profile.mcpServers: + profile.mcpServers = { + name: resolve_mcp_server_config(dict(cfg), persisted=True) + for name, cfg in profile.mcpServers.items() + } + unresolved_vars = sorted(set(re.findall(r"\$\{(\w+)\}", resolved_content))) context_file = _write_context_file(profile.name, raw_content) diff --git a/src/cli_agent_orchestrator/utils/mcp_resolution.py b/src/cli_agent_orchestrator/utils/mcp_resolution.py new file mode 100644 index 000000000..0e98f1d62 --- /dev/null +++ b/src/cli_agent_orchestrator/utils/mcp_resolution.py @@ -0,0 +1,142 @@ +"""Resolution of the bundled cao-mcp-server command for agent MCP configs. + +Bundled agent profiles declare the orchestration MCP server as the bare console +script ``cao-mcp-server``. That only resolves if the script's directory is on +the *agent subprocess's* ``PATH`` — which is not guaranteed across install +methods (an unactivated venv, a devcontainer, a ``pip install --prefix`` to a +non-standard location). When it fails to resolve, the agent starts without its +orchestration tools (handoff / assign / send_message) and silently no-ops. + +``resolve_cao_mcp_command`` rewrites the bare command to a PATH-independent +invocation, mirroring the three-tier fallback the Copilot provider already used +inline: + + 1. the ``cao-mcp-server`` script sitting next to the running interpreter + (the same environment that launched cao-server — the common case for + ``uv tool install`` / ``pipx``), then + 2. ``cao-mcp-server`` as resolved on ``PATH``, then + 3. `` -m cli_agent_orchestrator.mcp_server.server`` — always + runnable because it does not depend on a console script being on PATH. + +Any command other than the bare ``cao-mcp-server`` (e.g. a user's custom MCP +server, or an explicit absolute path) passes through unchanged. +""" + +import logging +import shutil +import sys +from pathlib import Path +from typing import List, Tuple + +logger = logging.getLogger(__name__) + +# The bundled orchestration MCP server's console-script name. +CAO_MCP_SERVER_COMMAND = "cao-mcp-server" + +# Module entrypoint equivalent of the console script — runnable by the +# interpreter directly, with no dependency on a script being on PATH. +CAO_MCP_SERVER_MODULE = "cli_agent_orchestrator.mcp_server.server" + +# Console-script filename to look for next to the interpreter. On Windows the +# script is installed as a .exe wrapper. +_SCRIPT_FILENAME = ( + f"{CAO_MCP_SERVER_COMMAND}.exe" if sys.platform == "win32" else CAO_MCP_SERVER_COMMAND +) + + +def _sibling_script() -> str: + """Absolute path to cao-mcp-server next to the running interpreter, or "".""" + if not sys.executable: # frozen/embedded interpreter — Path("") would raise + return "" + sibling = Path(sys.executable).with_name(_SCRIPT_FILENAME) + return str(sibling) if sibling.exists() else "" + + +def resolve_cao_mcp_command( + command: str, args: List[str], *, persisted: bool = False +) -> Tuple[str, List[str]]: + """Resolve a bare ``cao-mcp-server`` command to a PATH-independent form. + + Any command other than the bundled ``cao-mcp-server`` passes through + unchanged. For the bundled command, the resolution order depends on whether + the result is written to disk: + + - ``persisted=False`` (default, runtime providers that rebuild the launch + config every time): prefer the script next to the running interpreter — + an exact, hijack-proof match recomputed each launch. + - ``persisted=True`` (the resolved command is written to a config file the + provider reads later, e.g. Kiro/Q agent JSON): prefer the script as + resolved on ``PATH``. Tool installers (uv, pipx) keep a *stable* launcher + there (e.g. ``~/.local/bin/cao-mcp-server``) that survives upgrades, + whereas the interpreter-sibling path lives under a versioned venv dir that + ``uv tool upgrade`` relocates — which would leave a persisted path stale. + + Both orders fall back to the module entrypoint (`` -m + cli_agent_orchestrator.mcp_server.server``), which needs no console script + on PATH. + + Args: + command: The ``command`` field from an MCP server config. + args: The ``args`` field (may be empty). + persisted: Whether the resolved command will be written to disk and + reused across CAO upgrades (see above). + + Returns: + A ``(command, args)`` tuple. + """ + if command != CAO_MCP_SERVER_COMMAND: + return command, list(args) + + sibling = _sibling_script() + on_path = shutil.which(CAO_MCP_SERVER_COMMAND) + order = ( + [("PATH", on_path), ("sibling", sibling)] + if persisted + else [ + ("sibling", sibling), + ("PATH", on_path), + ] + ) + for label, candidate in order: + if candidate: + logger.debug("Resolved %s via %s: %s", command, label, candidate) + return candidate, list(args) + + # Module entrypoint via the current interpreter — runnable without any + # console script on PATH. Falls back to a bare ``python3`` only if + # sys.executable is unavailable (best effort in degenerate environments). + # Caller-supplied args are appended after the module path so flags reach + # the server in this tier too. + interpreter = sys.executable or "python3" + logger.debug("Resolved %s to module entrypoint via %s", command, interpreter) + return interpreter, ["-m", CAO_MCP_SERVER_MODULE, *args] + + +def resolve_mcp_server_config(config: dict, *, persisted: bool = False) -> dict: + """Return a copy of an MCP server config with its command resolved. + + ``persisted`` is forwarded to :func:`resolve_cao_mcp_command`; set it True + when the result is written to a config file the provider reads at a later + launch (e.g. Kiro/Q agent JSON). Convenience wrapper for the common + case of an entry shaped like ``{"command": ..., "args": [...], ...}``. + Leaves all other keys (``type``, ``env``, ...) untouched. + + Entries without a ``command`` (e.g. url/transport servers shaped + ``{"type": "http", "url": ...}``) pass through untouched — resolution only + applies to command-launched servers, and injecting ``command=""``/``args`` + into a command-less entry would corrupt it for providers that emit every + present key. + """ + if "command" not in config: + return dict(config) + resolved = dict(config) + command = resolved.get("command", "") + args = resolved.get("args", []) or [] + new_command, new_args = resolve_cao_mcp_command(command, args, persisted=persisted) + if (new_command, new_args) == (command, args): + # Passthrough (non-bundled command): don't write back keys the entry + # didn't have — e.g. don't add args=[] to an entry that omitted args. + return resolved + resolved["command"] = new_command + resolved["args"] = new_args + return resolved diff --git a/src/cli_agent_orchestrator/utils/opencode_config.py b/src/cli_agent_orchestrator/utils/opencode_config.py index 30d13eb05..5814050ad 100644 --- a/src/cli_agent_orchestrator/utils/opencode_config.py +++ b/src/cli_agent_orchestrator/utils/opencode_config.py @@ -15,6 +15,7 @@ from typing import Any, Dict, List from cli_agent_orchestrator.constants import OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE, SKILLS_DIR +from cli_agent_orchestrator.utils.mcp_resolution import resolve_cao_mcp_command logger = logging.getLogger(__name__) @@ -108,8 +109,13 @@ def translate_mcp_server_config(cao_config: Dict[str, Any]) -> Dict[str, Any]: - ``"enabled": true`` added - ``env`` → ``environment`` (OpenCode's key for process env vars) """ - command_str: str = cao_config.get("command", "") - args: List[str] = cao_config.get("args", []) + # Resolve the bundled cao-mcp-server console script to a PATH-independent + # invocation before flattening into OpenCode's command list. + # persisted=True: OpenCode reads this from opencode.json at launch, so prefer + # the stable PATH launcher over a versioned venv path that upgrades relocate. + command_str, args = resolve_cao_mcp_command( + cao_config.get("command", ""), cao_config.get("args", []) or [], persisted=True + ) full_command: List[str] = ([command_str] if command_str else []) + list(args) result: Dict[str, Any] = { diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py index e74fe9493..8fd699c05 100644 --- a/test/e2e/conftest.py +++ b/test/e2e/conftest.py @@ -9,7 +9,6 @@ """ import shutil -import subprocess import time import pytest @@ -29,32 +28,6 @@ def require_cao_server(): pytest.skip("CAO server not running — start with: uv run cao-server") -@pytest.fixture(scope="session", autouse=True) -def warmup_mcp_server_cache(): - """Pre-warm the uvx cache for cao-mcp-server. - - Agent profiles launch cao-mcp-server via ``uvx --from git+...``. On a cold - cache uvx must download and install ~80 packages, which takes ~20s and - exceeds Codex's default 10s MCP startup timeout. Running uvx once here - populates the cache so that all subsequent provider launches resolve - instantly (<3s). - """ - try: - subprocess.run( - [ - "uvx", - "--from", - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main", - "cao-mcp-server", - "--help", - ], - capture_output=True, - timeout=120, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - pass # Best-effort; tests may still work if cao-mcp-server is installed locally - - @pytest.fixture(scope="session", autouse=True) def require_tmux(): """Skip all E2E tests if tmux is not installed.""" diff --git a/test/e2e/script_runner/conftest.py b/test/e2e/script_runner/conftest.py index 74fa956d6..8928df896 100644 --- a/test/e2e/script_runner/conftest.py +++ b/test/e2e/script_runner/conftest.py @@ -4,12 +4,12 @@ exercise the REAL ``asyncio.create_subprocess_exec`` path directly (spawn/drain/ reap), never round-tripping over HTTP to a CAO server and never touching tmux. The parent ``test/e2e/conftest.py`` defines session-scoped ``autouse`` fixtures -(``require_cao_server``, ``warmup_mcp_server_cache``, ``require_tmux``) that -skip the ENTIRE e2e session when no server/tmux is present — that gating is -correct for the provider-handoff e2e tests but wrongly skips these -self-contained ones too. Overriding the same fixture names here (pytest fixture -resolution: nearest conftest wins) with no-ops scopes the server/tmux -requirement OUT of this subpackage only, without touching the parent module. +(``require_cao_server``, ``require_tmux``) that skip the ENTIRE e2e session +when no server/tmux is present — that gating is correct for the +provider-handoff e2e tests but wrongly skips these self-contained ones too. +Overriding the same fixture names here (pytest fixture resolution: nearest +conftest wins) with no-ops scopes the server/tmux requirement OUT of this +subpackage only, without touching the parent module. """ import pytest @@ -21,12 +21,6 @@ def require_cao_server(): return -@pytest.fixture(scope="session", autouse=True) -def warmup_mcp_server_cache(): - """No-op override: these tests never launch a provider CLI via cao-mcp-server.""" - return - - @pytest.fixture(scope="session", autouse=True) def require_tmux(): """No-op override: these tests never create a tmux terminal.""" diff --git a/test/providers/test_antigravity_cli_unit.py b/test/providers/test_antigravity_cli_unit.py index 04eea85ab..de9c0377e 100644 --- a/test/providers/test_antigravity_cli_unit.py +++ b/test/providers/test_antigravity_cli_unit.py @@ -275,6 +275,45 @@ def test_mcp_registration_writes_config(tmp_path, monkeypatch): assert "cao-mcp-server" not in data2.get("mcpServers", {}) +def test_mcp_registration_resolves_bundled_command(tmp_path, monkeypatch): + """The bundled bare `cao-mcp-server` command is resolved to a PATH- + independent invocation before it is written to mcp_config.json. agy reads + that file at a later launch (persisted=True), so the stable PATH launcher + is preferred over the versioned interpreter-sibling path.""" + from cli_agent_orchestrator.models.agent_profile import AgentProfile + + cfg = tmp_path / "mcp_config.json" + profile = AgentProfile( + name="reviewer_gemini", + description="Reviewer", + system_prompt="You review code.", + mcpServers={"cao-mcp-server": {"command": "cao-mcp-server", "args": []}}, + ) + p = make_provider(agent_profile="reviewer_gemini") + MOD = "cli_agent_orchestrator.utils.mcp_resolution" + with ( + patch( + "cli_agent_orchestrator.providers.antigravity_cli.shutil.which", + return_value="/usr/local/bin/agy", + ), + patch( + "cli_agent_orchestrator.providers.antigravity_cli.load_agent_profile", + return_value=profile, + ), + patch.object(AntigravityCliProvider, "_mcp_config_path", return_value=cfg), + patch(f"{MOD}._sibling_script", return_value="/versioned/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value="/home/u/.local/bin/cao-mcp-server"), + ): + p._build_agy_command() + import json + + entry = json.loads(cfg.read_text())["mcpServers"]["cao-mcp-server"] + # persisted=True prefers the stable PATH launcher, not the bare command + # or the versioned sibling. + assert entry["command"] == "/home/u/.local/bin/cao-mcp-server" + assert entry["args"] == [] + + # --------------------------------------------------------------------------- # # Misc lifecycle # --------------------------------------------------------------------------- # diff --git a/test/providers/test_claude_code_unit.py b/test/providers/test_claude_code_unit.py index 1bc16fadc..610e47b10 100644 --- a/test/providers/test_claude_code_unit.py +++ b/test/providers/test_claude_code_unit.py @@ -1144,6 +1144,31 @@ def test_build_command_mcp_injects_terminal_id(self, mock_load): server_env = mcp_data["mcpServers"]["cao-mcp-server"]["env"] assert server_env["CAO_TERMINAL_ID"] == "term-42" + @patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile") + def test_build_command_resolves_bundled_mcp_command(self, mock_load): + """The bare cao-mcp-server command is resolved to a PATH-independent + invocation in the written MCP config (wiring guard: a refactor that + drops the resolve_mcp_server_config call must fail this test).""" + mock_profile = MagicMock() + mock_profile.model = None + mock_profile.system_prompt = None + mock_profile.mcpServers = { + "cao-mcp-server": {"type": "stdio", "command": "cao-mcp-server", "args": []} + } + mock_profile.permissionMode = None + mock_load.return_value = mock_profile + + provider = ClaudeCodeProvider("term-43", "test-session", "window-0", "test-agent") + MOD = "cli_agent_orchestrator.utils.mcp_resolution" + with ( + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value=None), + ): + command = provider._build_claude_command() + + mcp_data = _extract_mcp_config(command) + assert mcp_data["mcpServers"]["cao-mcp-server"]["command"] == "/venv/bin/cao-mcp-server" + @patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile") def test_build_command_mcp_preserves_existing_env(self, mock_load): """Test that existing env vars in MCP config are preserved when injecting CAO_TERMINAL_ID.""" diff --git a/test/providers/test_codex_provider_unit.py b/test/providers/test_codex_provider_unit.py index 360819127..c8b41a66e 100644 --- a/test/providers/test_codex_provider_unit.py +++ b/test/providers/test_codex_provider_unit.py @@ -178,6 +178,33 @@ def test_build_command_with_mcp_servers(self, mock_load_profile): # Tool timeout must be a TOML float (600.0) for Codex's f64 deserializer assert "mcp_servers.cao-mcp-server.tool_timeout_sec=600.0" in command + @patch("cli_agent_orchestrator.providers.codex.resolve_mcp_server_config") + @patch("cli_agent_orchestrator.providers.codex.load_agent_profile") + def test_bundled_mcp_command_is_resolved(self, mock_load_profile, mock_resolve): + """The bundled bare cao-mcp-server command is run through the resolver + before being emitted as a -c override.""" + mock_profile = MagicMock() + mock_profile.model = None + mock_profile.system_prompt = "You are a supervisor." + mock_profile.mcpServers = { + "cao-mcp-server": {"type": "stdio", "command": "cao-mcp-server", "args": []} + } + mock_profile.codexProfile = None + mock_load_profile.return_value = mock_profile + # Simulate the resolver returning a PATH-independent absolute path. + mock_resolve.side_effect = lambda cfg: { + **cfg, + "command": "/home/u/.local/bin/cao-mcp-server", + "args": [], + } + + provider = CodexProvider("test1234", "test-session", "window-0", "code_supervisor") + command = provider._build_codex_command() + + # The resolver was invoked and its resolved command appears in the override. + assert mock_resolve.called + assert 'mcp_servers.cao-mcp-server.command="/home/u/.local/bin/cao-mcp-server"' in command + @patch("cli_agent_orchestrator.providers.codex.load_agent_profile") def test_build_command_with_mcp_servers_env(self, mock_load_profile): mock_profile = MagicMock() diff --git a/test/providers/test_copilot_cli_unit.py b/test/providers/test_copilot_cli_unit.py index 8dbeb969d..b75a3e060 100644 --- a/test/providers/test_copilot_cli_unit.py +++ b/test/providers/test_copilot_cli_unit.py @@ -547,6 +547,19 @@ def test_build_runtime_mcp_config_includes_terminal_id(self): assert "cao-mcp-server" in runtime_cfg["mcpServers"] assert runtime_cfg["mcpServers"]["cao-mcp-server"]["env"]["CAO_TERMINAL_ID"] == "abc12345" + def test_build_runtime_mcp_config_resolves_bundled_command(self): + """The bundled cao-mcp-server is resolved to a PATH-independent + invocation (wiring guard: a refactor that drops the + resolve_cao_mcp_command call must fail this test).""" + provider = CopilotCliProvider("abc12345", "test-session", "window-0") + MOD = "cli_agent_orchestrator.utils.mcp_resolution" + with ( + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value=None), + ): + runtime_cfg = json.loads(provider._build_runtime_mcp_config()) + assert runtime_cfg["mcpServers"]["cao-mcp-server"]["command"] == "/venv/bin/cao-mcp-server" + def test_cleanup_resets_initialized_state(self): provider = CopilotCliProvider("test1234", "test-session", "window-0") provider._initialized = True diff --git a/test/providers/test_cursor_cli_unit.py b/test/providers/test_cursor_cli_unit.py index 57b52e36b..1870ec57d 100644 --- a/test/providers/test_cursor_cli_unit.py +++ b/test/providers/test_cursor_cli_unit.py @@ -765,6 +765,38 @@ def test_mcp_servers_forwarded_via_plugin_dir(self, mock_load): assert "cao-mcp-server" in servers assert servers["cao-mcp-server"]["env"]["CAO_TERMINAL_ID"] == "test-tid" + @patch("cli_agent_orchestrator.providers.cursor_cli.load_agent_profile") + def test_mcp_resolves_bundled_command_in_manifest(self, mock_load): + # Wiring guard: the bare cao-mcp-server command must be rewritten to + # a PATH-independent invocation in the written plugin manifest. A + # refactor that drops the resolve_mcp_server_config call fails this. + import json + from pathlib import Path + + profile = MagicMock() + profile.model = None + profile.system_prompt = None + profile.mcpServers = {"cao-mcp-server": {"command": "cao-mcp-server", "args": []}} + mock_load.return_value = profile + provider = make_provider(agent_profile="developer") + MOD = "cli_agent_orchestrator.utils.mcp_resolution" + # NOTE: mcp_resolution and cursor_cli import the SAME shutil module + # object, so a blanket which->None would break the provider's own + # cursor-binary lookup (stubbed by the autouse fixture). Only the + # cao-mcp-server lookup may miss. + which_cursor_keeps_working = lambda name: ( + None if name == "cao-mcp-server" else "/usr/local/bin/cursor-agent" + ) + with ( + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", side_effect=which_cursor_keeps_working), + ): + cmd = provider._build_cursor_command() + m = re.search(r"--plugin-dir\s+(\S+)", cmd) + assert m is not None + manifest = json.loads((Path(m.group(1)) / "plugin.json").read_text(encoding="utf-8")) + assert manifest["mcpServers"]["cao-mcp-server"]["command"] == "/venv/bin/cao-mcp-server" + @patch("cli_agent_orchestrator.providers.cursor_cli.load_agent_profile") def test_mcp_preserves_existing_cao_terminal_id(self, mock_load): # The constructor's terminal_id must NOT override an diff --git a/test/providers/test_kimi_cli_unit.py b/test/providers/test_kimi_cli_unit.py index 7612d54e4..c1d45dfd9 100644 --- a/test/providers/test_kimi_cli_unit.py +++ b/test/providers/test_kimi_cli_unit.py @@ -599,6 +599,31 @@ def test_build_command_with_mcp_config(self, mock_load, tmp_path): # No --config flag (modifies config.toml directly to avoid breaking OAuth) assert "--config" not in command + @patch("cli_agent_orchestrator.providers.kimi_cli.load_agent_profile") + def test_build_command_resolves_bundled_mcp_command(self, mock_load, tmp_path): + """The bare cao-mcp-server command is resolved to a PATH-independent + invocation in the emitted --mcp-config JSON (wiring guard: a refactor + that drops the resolve_mcp_server_config call must fail this test).""" + mock_profile = MagicMock() + mock_profile.model = None + mock_profile.system_prompt = None + mock_profile.mcpServers = { + "cao-mcp-server": {"type": "stdio", "command": "cao-mcp-server", "args": []} + } + mock_load.return_value = mock_profile + + provider = KimiCliProvider("term-1", "session-1", "window-1", agent_profile="dev") + MOD = "cli_agent_orchestrator.utils.mcp_resolution" + with ( + patch("cli_agent_orchestrator.providers.kimi_cli.Path.home", return_value=tmp_path), + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value=None), + ): + command = provider._build_kimi_command() + + assert "/venv/bin/cao-mcp-server" in command + provider.cleanup() + @patch("cli_agent_orchestrator.providers.kimi_cli.load_agent_profile") def test_build_command_creates_agent_yaml(self, mock_load): """Test that agent YAML and system prompt files are created correctly.""" diff --git a/test/services/test_install_service.py b/test/services/test_install_service.py index 75442fefd..a4785c8e8 100644 --- a/test/services/test_install_service.py +++ b/test/services/test_install_service.py @@ -226,6 +226,43 @@ def test_install_from_builtin_writes_kiro_config( assert kiro_config["name"] == "developer" assert kiro_config["mcpServers"]["service"]["env"]["API_TOKEN"] == "secret-token" + def test_install_kiro_resolves_bundled_mcp_command_persisted( + self, install_paths: dict[str, Path] + ) -> None: + """The bare cao-mcp-server command in a profile is resolved with + persisted=True (PATH launcher preferred over the versioned venv + sibling) in the written Kiro agent JSON — the config is consumed + verbatim by kiro at later launches, so resolution must happen here. + Wiring guard: dropping the resolve call in install_agent fails this.""" + profile_text = ( + "---\n" + "name: cao-agent\n" + "description: Test agent\n" + "role: developer\n" + "mcpServers:\n" + " cao-mcp-server:\n" + " command: cao-mcp-server\n" + " args: []\n" + "prompt: |\n Do work\n" + "---\n" + "Body.\n" + ) + local_profile = install_paths["local_store_dir"] / "cao-agent.md" + local_profile.write_text(profile_text, encoding="utf-8") + + MOD = "cli_agent_orchestrator.utils.mcp_resolution" + with ( + patch(f"{MOD}._sibling_script", return_value="/versioned/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value="/home/u/.local/bin/cao-mcp-server"), + ): + result = install_agent("cao-agent", "kiro_cli") + + assert result.success is True + kiro_config = json.loads((install_paths["kiro_dir"] / "cao-agent.json").read_text()) + entry = kiro_config["mcpServers"]["cao-mcp-server"] + # persisted=True prefers the stable PATH launcher. + assert entry["command"] == "/home/u/.local/bin/cao-mcp-server" + def test_install_sets_env_vars_before_profile_loading( self, install_paths: dict[str, Path] ) -> None: @@ -819,6 +856,36 @@ def test_detects_cao_by_args_even_with_different_key(self): out = _inject_kiro_mcp_timeout(servers) assert out["orchestrator"]["timeout"] == _KIRO_MCP_TOOL_TIMEOUT_MS + def test_detects_cao_by_command_even_with_different_key(self): + from cli_agent_orchestrator.services.install_service import ( + _KIRO_MCP_TOOL_TIMEOUT_MS, + _inject_kiro_mcp_timeout, + ) + + # Bare console script (bundled profile form) and a resolved absolute + # path (what resolve_mcp_server_config writes) both carry the marker + # in the command, not the args. + for command in ("cao-mcp-server", "/home/u/.local/bin/cao-mcp-server"): + servers = {"orchestrator": {"command": command, "args": []}} + out = _inject_kiro_mcp_timeout(servers) + assert out["orchestrator"]["timeout"] == _KIRO_MCP_TOOL_TIMEOUT_MS + + def test_detects_cao_module_entrypoint_fallback(self): + from cli_agent_orchestrator.services.install_service import ( + _KIRO_MCP_TOOL_TIMEOUT_MS, + _inject_kiro_mcp_timeout, + ) + + # The resolver's last-resort form: -m . + servers = { + "orchestrator": { + "command": "/venv/bin/python3", + "args": ["-m", "cli_agent_orchestrator.mcp_server.server"], + } + } + out = _inject_kiro_mcp_timeout(servers) + assert out["orchestrator"]["timeout"] == _KIRO_MCP_TOOL_TIMEOUT_MS + def test_leaves_non_cao_servers_untouched(self): from cli_agent_orchestrator.services.install_service import _inject_kiro_mcp_timeout diff --git a/test/utils/test_bundled_profiles.py b/test/utils/test_bundled_profiles.py new file mode 100644 index 000000000..d1504903d --- /dev/null +++ b/test/utils/test_bundled_profiles.py @@ -0,0 +1,58 @@ +"""Guards on the shipped bundled agent profiles. + +These assert the contract of the profiles that ship inside the wheel — most +importantly that their cao-mcp-server MCP server is declared as the bare +console-script command, NOT a per-launch network fetch (the old +``uvx --from git+https://...`` form, which cold-fetched the whole package on +every agent launch and tripped provider MCP startup timeouts). +""" + +from importlib import resources + +import frontmatter +import pytest + +from cli_agent_orchestrator.utils.agent_profiles import parse_agent_profile_text + +# Enumerate dynamically so a newly added bundled profile is guarded +# automatically instead of silently skipped. +BUNDLED_PROFILES = sorted( + item.name[: -len(".md")] + for item in resources.files("cli_agent_orchestrator.agent_store").iterdir() + if item.name.endswith(".md") +) + + +def _load_bundled(name: str): + text = (resources.files("cli_agent_orchestrator.agent_store") / f"{name}.md").read_text() + return parse_agent_profile_text(text, name) + + +@pytest.mark.parametrize("name", BUNDLED_PROFILES) +def test_bundled_profile_uses_console_script_mcp_command(name): + """Bundled profiles must declare cao-mcp-server as the bare console script.""" + profile = _load_bundled(name) + assert profile.mcpServers, f"{name} should declare a cao-mcp-server" + entry = profile.mcpServers["cao-mcp-server"] + assert ( + entry["command"] == "cao-mcp-server" + ), f"{name} must use the installed console script, not a network fetch" + assert entry.get("args", []) == [] + + +@pytest.mark.parametrize("name", BUNDLED_PROFILES) +def test_bundled_profile_does_not_network_fetch_mcp_server(name): + """Regression guard: no profile may reintroduce the uvx git+https fetch.""" + raw = (resources.files("cli_agent_orchestrator.agent_store") / f"{name}.md").read_text() + assert ( + "git+https://github.com/awslabs/cli-agent-orchestrator" not in raw + ), f"{name} reintroduced the cold uvx git+https fetch" + + +@pytest.mark.parametrize("name", BUNDLED_PROFILES) +def test_bundled_profile_parses(name): + """Each bundled profile must parse with valid frontmatter.""" + text = (resources.files("cli_agent_orchestrator.agent_store") / f"{name}.md").read_text() + meta = frontmatter.loads(text).metadata + assert meta.get("name") == name + assert meta.get("description") diff --git a/test/utils/test_mcp_resolution.py b/test/utils/test_mcp_resolution.py new file mode 100644 index 000000000..6f470fa3d --- /dev/null +++ b/test/utils/test_mcp_resolution.py @@ -0,0 +1,249 @@ +"""Unit tests for cao-mcp-server command resolution.""" + +from unittest.mock import patch + +from cli_agent_orchestrator.utils.mcp_resolution import ( + CAO_MCP_SERVER_MODULE, + resolve_cao_mcp_command, + resolve_mcp_server_config, +) + +MOD = "cli_agent_orchestrator.utils.mcp_resolution" + + +class TestPassthrough: + """Non-bundled commands are never rewritten.""" + + def test_passthrough_for_non_cao_command(self): + cmd, args = resolve_cao_mcp_command("uvx", ["--from", "pkg", "thing"]) + assert cmd == "uvx" + assert args == ["--from", "pkg", "thing"] + + def test_passthrough_with_nonempty_args_returns_copy(self): + """Passthrough must copy the args list, not alias the caller's.""" + original = ["--from", "pkg"] + _, args = resolve_cao_mcp_command("uvx", original) + assert args == original + args.append("mutated") + assert original == ["--from", "pkg"] + + +class TestSiblingScript: + """_sibling_script finds the console script next to sys.executable.""" + + def test_returns_path_when_script_exists(self, tmp_path): + from cli_agent_orchestrator.utils.mcp_resolution import ( + _SCRIPT_FILENAME, + _sibling_script, + ) + + fake_python = tmp_path / "python3" + fake_python.touch() + (tmp_path / _SCRIPT_FILENAME).touch() + with patch(f"{MOD}.sys") as mock_sys: + mock_sys.executable = str(fake_python) + assert _sibling_script() == str(tmp_path / _SCRIPT_FILENAME) + + def test_returns_empty_when_script_missing(self, tmp_path): + from cli_agent_orchestrator.utils.mcp_resolution import _sibling_script + + fake_python = tmp_path / "python3" + fake_python.touch() + with patch(f"{MOD}.sys") as mock_sys: + mock_sys.executable = str(fake_python) + assert _sibling_script() == "" + + def test_returns_empty_for_empty_sys_executable(self): + from cli_agent_orchestrator.utils.mcp_resolution import _sibling_script + + with patch(f"{MOD}.sys") as mock_sys: + mock_sys.executable = "" + assert _sibling_script() == "" + + +class TestArgsPreservation: + """Caller-supplied args survive resolution in every tier.""" + + def test_script_resolution_preserves_args(self): + with ( + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value=None), + ): + cmd, args = resolve_cao_mcp_command("cao-mcp-server", ["--log-level", "debug"]) + assert cmd == "/venv/bin/cao-mcp-server" + assert args == ["--log-level", "debug"] + + def test_module_fallback_appends_args_after_module(self): + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value=None), + patch(f"{MOD}.sys") as mock_sys, + ): + mock_sys.executable = "/venv/bin/python3" + cmd, args = resolve_cao_mcp_command("cao-mcp-server", ["--log-level", "debug"]) + assert cmd == "/venv/bin/python3" + assert args == ["-m", CAO_MCP_SERVER_MODULE, "--log-level", "debug"] + + def test_resolved_args_are_a_copy(self): + original = ["--flag"] + with ( + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value=None), + ): + _, args = resolve_cao_mcp_command("cao-mcp-server", original) + args.append("mutated") + assert original == ["--flag"] + + +class TestRuntimeResolution: + """persisted=False (default): prefer the interpreter-sibling script.""" + + def test_prefers_sibling_over_path(self): + with ( + patch(f"{MOD}._sibling_script", return_value="/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value="/usr/local/bin/cao-mcp-server"), + ): + cmd, args = resolve_cao_mcp_command("cao-mcp-server", []) + assert cmd == "/venv/bin/cao-mcp-server" + assert args == [] + + def test_falls_back_to_path_when_no_sibling(self): + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value="/usr/local/bin/cao-mcp-server"), + ): + cmd, args = resolve_cao_mcp_command("cao-mcp-server", []) + assert cmd == "/usr/local/bin/cao-mcp-server" + assert args == [] + + def test_falls_back_to_module_entrypoint(self): + """No sibling, nothing on PATH → run the module via the interpreter.""" + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value=None), + patch(f"{MOD}.sys") as mock_sys, + ): + mock_sys.executable = "/usr/bin/python3" + cmd, args = resolve_cao_mcp_command("cao-mcp-server", []) + assert cmd == "/usr/bin/python3" + assert args == ["-m", CAO_MCP_SERVER_MODULE] + + def test_module_fallback_tolerates_empty_sys_executable(self): + """A frozen/embedded interpreter (sys.executable == '') must not crash.""" + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value=None), + patch(f"{MOD}.sys") as mock_sys, + ): + mock_sys.executable = "" + cmd, args = resolve_cao_mcp_command("cao-mcp-server", []) + assert cmd == "python3" # best-effort fallback + assert args == ["-m", CAO_MCP_SERVER_MODULE] + + +class TestPersistedResolution: + """persisted=True: prefer the stable PATH launcher (survives upgrades).""" + + def test_prefers_path_over_sibling(self): + """The versioned sibling path would go stale on upgrade; prefer PATH.""" + with ( + patch(f"{MOD}._sibling_script", return_value="/versioned/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value="/home/u/.local/bin/cao-mcp-server"), + ): + cmd, args = resolve_cao_mcp_command("cao-mcp-server", [], persisted=True) + assert cmd == "/home/u/.local/bin/cao-mcp-server" + assert args == [] + + def test_falls_back_to_sibling_when_not_on_path(self): + with ( + patch(f"{MOD}._sibling_script", return_value="/versioned/venv/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value=None), + ): + cmd, args = resolve_cao_mcp_command("cao-mcp-server", [], persisted=True) + assert cmd == "/versioned/venv/bin/cao-mcp-server" + assert args == [] + + +class TestResolveMcpServerConfig: + """resolve_mcp_server_config wraps the resolver over a config dict.""" + + def test_resolves_command_and_preserves_other_keys(self): + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value="/usr/local/bin/cao-mcp-server"), + ): + out = resolve_mcp_server_config( + { + "type": "stdio", + "command": "cao-mcp-server", + "args": [], + "env": {"CAO_TERMINAL_ID": "abc"}, + } + ) + assert out["command"] == "/usr/local/bin/cao-mcp-server" + assert out["args"] == [] + assert out["type"] == "stdio" + assert out["env"] == {"CAO_TERMINAL_ID": "abc"} + + def test_does_not_mutate_input(self): + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value="/usr/local/bin/cao-mcp-server"), + ): + original = {"command": "cao-mcp-server", "args": []} + resolve_mcp_server_config(original) + assert original["command"] == "cao-mcp-server" + + def test_idempotent_after_resolution(self): + """Re-resolving an already-resolved config is a no-op passthrough. + + Matters because install_service resolves, then the OpenCode translator + may resolve again; the second pass must not double-transform. + """ + with ( + patch(f"{MOD}._sibling_script", return_value=""), + patch(f"{MOD}.shutil.which", return_value="/usr/local/bin/cao-mcp-server"), + ): + once = resolve_mcp_server_config({"command": "cao-mcp-server", "args": []}) + twice = resolve_mcp_server_config(once) + assert once == twice + assert twice["command"] == "/usr/local/bin/cao-mcp-server" + + def test_persisted_forwarded(self): + with ( + patch(f"{MOD}._sibling_script", return_value="/versioned/bin/cao-mcp-server"), + patch(f"{MOD}.shutil.which", return_value="/stable/bin/cao-mcp-server"), + ): + out = resolve_mcp_server_config( + {"command": "cao-mcp-server", "args": []}, persisted=True + ) + assert out["command"] == "/stable/bin/cao-mcp-server" + + def test_custom_server_passthrough(self): + out = resolve_mcp_server_config( + {"type": "stdio", "command": "my-server", "args": ["--port", "9000"]} + ) + assert out["command"] == "my-server" + assert out["args"] == ["--port", "9000"] + + def test_commandless_url_server_passes_through_untouched(self): + """A url/transport server (no command) must not gain command/args keys. + + Providers emit every present key, so injecting command=""/args=[] into + an http/sse entry would corrupt it (e.g. codex would emit an invalid + empty command override). + """ + original = {"type": "http", "url": "https://example.com/mcp"} + out = resolve_mcp_server_config(original) + assert out == original + assert "command" not in out + assert "args" not in out + # Still a copy, not the same object. + out["type"] = "mutated" + assert original["type"] == "http" + + def test_command_without_args_key_does_not_gain_args_for_passthrough(self): + """A non-cao command entry that omits args stays args-less.""" + out = resolve_mcp_server_config({"type": "stdio", "command": "my-server"}) + assert out["command"] == "my-server" + assert "args" not in out diff --git a/test/utils/test_opencode_config.py b/test/utils/test_opencode_config.py index e67a680e7..b01cdd3d2 100644 --- a/test/utils/test_opencode_config.py +++ b/test/utils/test_opencode_config.py @@ -136,29 +136,42 @@ def test_no_env_key_absent(self): assert "environment" not in result assert "env" not in result - def test_real_cao_mcp_server_entry(self): - """Full translation of the cao-mcp-server entry as it appears in agent profiles.""" + def test_custom_uvx_entry_passes_through(self): + """A user-defined uvx-based MCP server is translated verbatim. + + Only the bundled ``cao-mcp-server`` command is rewritten (see + test_bundled_cao_mcp_server_command_is_resolved); any other command — + including a user's own ``uvx --from ...`` server — flattens unchanged. + """ cao_cfg = { "type": "stdio", "command": "uvx", - "args": [ - "--from", - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main", - "cao-mcp-server", - ], + "args": ["--from", "some-pkg", "my-mcp-server"], } result = translate_mcp_server_config(cao_cfg) assert result == { "type": "local", - "command": [ - "uvx", - "--from", - "git+https://github.com/awslabs/cli-agent-orchestrator.git@main", - "cao-mcp-server", - ], + "command": ["uvx", "--from", "some-pkg", "my-mcp-server"], "enabled": True, } + def test_bundled_cao_mcp_server_command_is_resolved(self): + """The bare cao-mcp-server command is resolved to a PATH-independent form. + + The bundled profiles declare ``command: cao-mcp-server``; the translator + resolves it so OpenCode launches it without depending on + the script being on the agent subprocess's PATH. + """ + result = translate_mcp_server_config( + {"type": "stdio", "command": "cao-mcp-server", "args": []} + ) + assert result["type"] == "local" + assert result["enabled"] is True + # Resolved away from the bare console-script name into a concrete + # invocation (abs path to the script, or ` -m ...`). + assert result["command"][0] != "cao-mcp-server" + assert result["command"] # non-empty + class TestReadConfig: def test_missing_file_returns_skeleton(self, tmp_config: Path):