diff --git a/docs/guides/sdlc-pipeline.md b/docs/guides/sdlc-pipeline.md index f0434e3ff8..511533b410 100644 --- a/docs/guides/sdlc-pipeline.md +++ b/docs/guides/sdlc-pipeline.md @@ -1531,7 +1531,7 @@ HITL decision is created. Consider adding role-based file restrictions to minimi **Default: on since [#1942](https://github.com/jwbron/egg/issues/1942)** — set `EGG_MCP_TOOLS=false` per pipeline to opt out. -Sandbox agents call pipeline lifecycle operations (BRC consensus, HITL decisions, phase context, progress signals, task completion) through first-class Claude Agent SDK MCP tools rather than shelling out to `egg-contract` / `egg-orch` via `Bash`. The tools run **in-process** via `claude_agent_sdk.create_sdk_mcp_server` — no new network service, no new auth layer, no new process. See the [Agent MCP Tools reference](../reference/agent-tools.md) for the full inventory across 5 namespaces (`mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`), schemas, and architecture. +Sandbox agents call pipeline lifecycle operations (BRC consensus, HITL decisions, phase context, progress signals, task completion) plus the Confluence/Jira gateway-route mirrors through first-class Claude Agent SDK MCP tools rather than shelling out to `egg-contract` / `egg-orch` (or the `confluence` / `jira` bash wrappers) via `Bash`. The tools run **in-process** via `claude_agent_sdk.create_sdk_mcp_server` — no new network service, no new auth layer, no new process. See the [Agent MCP Tools reference](../reference/agent-tools.md) for the full 45-verb inventory across 7 namespaces (`mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`, `mcp__confluence__*`, `mcp__jira__*`), schemas, and architecture. **Opt out per-pipeline.** Iteration 1 (#1765) shipped this default-off; #1942 flipped the default after the wire-up stabilised. Set `EGG_MCP_TOOLS` to a falsy value (`false`, `0`, `no`, `off`) on your sandbox pod env to disable: diff --git a/docs/index.md b/docs/index.md index 6319abf859..52699f462a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -80,7 +80,7 @@ This index helps both humans and LLMs navigate the documentation efficiently. | [Orchestrator CLI](reference/orchestrator-cli.md) | Full `egg-orch` command reference for pipelines, phases, decisions, containers | | [SDLC Contract](reference/sdlc-contract.md) | Full `egg-contract` command reference for tracking tasks, commits, decisions | | [MCP Deployment Tools](reference/mcp-deployment-tools.md) | Six k8s-facing MCP tools: `get_deployment_context`, `validate_deployment_manifests`, `prune_stale_worktrees`, `validate_network_isolation`, `rebuild_and_rollout`, `get_service_logs` | -| [Agent MCP Tools](reference/agent-tools.md) | In-process SDK MCP tools sandbox agents call on the `tool_use` stream (5 namespaces: `mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`); on by default — set `EGG_MCP_TOOLS=false` to opt out | +| [Agent MCP Tools](reference/agent-tools.md) | In-process SDK MCP tools sandbox agents call on the `tool_use` stream (45 verbs across 7 namespaces: `mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`, `mcp__confluence__*`, `mcp__jira__*`); on by default — set `EGG_MCP_TOOLS=false` to opt out | | [Agent Wait Patterns](reference/agent-wait-patterns.md) | Canonical `egg-orch message wait-loop` idiom for BRC STAY ALIVE, the five anti-patterns to avoid, the `egg-orch message wait` exit-code contract, the `HEARTBEAT` metadata schema, the `EGG_MESSAGE_POLL_MAX_WAIT` / `EGG_ORCH_WAITRESS_THREADS` env-var couplings, §7 host-side `egg-orch pipeline wait-status` for event-driven pipeline monitoring, §10 BRC event-pump wrapper wait surface (conditional filter construction, heartbeat ownership migration, `slice_id` propagation invariant, and idle-budget escalation path), and §10.9 per-event prompt composer + preamble collapse (prompt shape, full git-log delta, `EGG_BRC_MEMORY` matrix) | | [Jira Wrapper](reference/jira-wrapper.md) | `/api/v1/jira/*` gateway endpoints — read verbs (ticket read, JQL search with static project-scope extraction, ticket comments, GET-only execute passthrough) plus the bounded write extension (`ticket/create`, `ticket/edit`, `ticket/comment/add`, `issue-link/create`); private-mode only; project allowlist via `config/context-filters.yaml`; per-verb body schema with size caps; ADF wrapping for plain-text `description` / `comment` bodies (`gateway/jira_adf.py`); caller-supplied idempotency key with 5-minute in-process cache (`gateway/jira_idempotency.py`); operator-configurable `jira.link_types` / `jira.epic_link_field` knobs; audit redaction never logs body content; `not_found` envelope on read 404s | | [Confluence Wrapper](reference/confluence-wrapper.md) | `/api/v1/confluence/*` read-only gateway endpoints (page read, descendants, footer/inline comments with v1 fallback, space list/pages, CQL search with static space-scope extraction, GET-only execute passthrough); private-mode only; space allowlist via `config/context-filters.yaml`; `not_found` envelope; response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`); future write-verb extension points | diff --git a/docs/reference/agent-tools.md b/docs/reference/agent-tools.md index a828ca0fc4..6034dfdce6 100644 --- a/docs/reference/agent-tools.md +++ b/docs/reference/agent-tools.md @@ -2,9 +2,10 @@ > Sandbox agents can call pipeline lifecycle operations (BRC consensus, > HITL decisions, phase context, progress signals, task completion) -> through first-class MCP tools on the Claude -> Agent SDK `tool_use` stream, instead of shelling out to -> `egg-contract` / `egg-orch` via `Bash`. +> plus the gateway's Confluence/Jira routes through first-class MCP +> tools on the Claude Agent SDK `tool_use` stream, instead of shelling +> out to `egg-contract` / `egg-orch` (or the `confluence` / `jira` +> wrappers) via `Bash`. The tools are exposed as an in-process SDK MCP server built with [`claude_agent_sdk.create_sdk_mcp_server`](https://github.com/anthropics/claude-agent-sdk-python) @@ -19,13 +20,31 @@ iteration 2 (additional verbs covering the rest of the capability audit) is tracked in [#1917](https://github.com/jwbron/egg/issues/1917). +The `mcp__confluence__*` / `mcp__jira__*` namespaces +([#2994](https://github.com/jwbron/egg/issues/2994)) are a thin +**presentation layer over the gateway's `/api/v1/{confluence,jira}/*` +routes** — the same routes the `sandbox/scripts/{confluence,jira}` bash +wrappers POST to. Their handlers carry **no Atlassian credentials** and +add **no new capability**: every policy (space/project allowlist, +read-only vs. the four Jira write routes, CQL/JQL scope extraction, +response redaction, the `private_mode_required` gate) stays enforced at +the gateway. They exist so the routes are visible in the agent's tool +manifest every turn instead of being prose in `environment.md` the agent +has to recall — the motivating discovery failure in +[Khan/webapp#39894](https://github.com/Khan/webapp/pull/39894), where an +agent saw no `mcp__confluence__*` in its manifest and *guessed* a space +list rather than calling `confluence space list`. Naming matches the +host MCP namespaces (which are deliberately **not** exposed to the +sandbox), so planner-authored task text that references +`mcp__confluence__*` resolves to the restricted sandbox tools. + ## Flag — `EGG_MCP_TOOLS` The MCP tool surface is **on by default** since [#1942](https://github.com/jwbron/egg/issues/1942). The env var now acts as a kill-switch: | Flag | Effect | |------|--------| -| `EGG_MCP_TOOLS` unset or any value not listed below | **Default.** Registers the 28 tools (one server per namespace) on `options.mcp_servers` and appends `SYSTEM_PROMPT_NUDGE` to `options.system_prompt`. | +| `EGG_MCP_TOOLS` unset or any value not listed below | **Default.** Registers the 45 tools (one server per namespace) on `options.mcp_servers` and appends `SYSTEM_PROMPT_NUDGE` to `options.system_prompt`. | | `EGG_MCP_TOOLS=false` (or `0` / `no` / `off`) | Opt-out. Code path is byte-identical to the pre-#1765 behaviour — no `mcp_servers` registration, no prompt changes, no import cost. | Iteration 1 (#1765) shipped the flag default-off while the wire-up burned in. @@ -39,9 +58,9 @@ Compose, or the `env` stanza on any submit-task payload. See (EGG_MCP_TOOLS flag)](../guides/sdlc-pipeline.md#agent-mcp-tools-egg_mcp_tools-flag) for the per-pipeline recipe. -## Tool inventory (28 verbs) +## Tool inventory (45 verbs) -All 28 tools are registered as `@tool`-decorated wrappers in +All 45 tools are registered as `@tool`-decorated wrappers in `sandbox/egg_agent_tools/tools/*.py`. The raw `@tool` name is the verb itself (e.g. `"propose"`, `"register_open_question"`). @@ -50,8 +69,9 @@ itself (e.g. `"propose"`, `"register_open_question"`). The SDK renders an MCP tool in `tool_use` blocks as `mcp____`. `build_sandbox_mcp_server` returns a `{namespace: server}` dict — one SDK MCP server per -namespace, keyed by `sdlc`, `brc`, `phase`, `progress`, or -`task` — and `shared/egg_agent/client.py::run_agent_async` +namespace, keyed by `sdlc`, `brc`, `phase`, `progress`, `task`, +`confluence`, or `jira` — and +`shared/egg_agent/client.py::run_agent_async` merges that dict into `options.mcp_servers` unless `EGG_MCP_TOOLS` is explicitly falsy. With raw `@tool` names declared as plain verbs, Claude's composition naturally produces the semantic names in the @@ -153,20 +173,64 @@ required is tracked as a separate follow-up after iter-2 burn-in | `mcp__task__update_notes` | Append implementation notes to a task. | `handlers.task.update_notes` | `egg-contract update-notes` | | `mcp__task__mark_gap` | Append a structured coverage-gap entry to `phases.

.tasks..gaps[]`. **Tester role writes; coder role reads.** Handler stamps `created_at` (ISO-8601 UTC) and generates a stable `gap-` id from `max(existing) + 1`. Validation rejects missing `from_role` / `to_role` / `description`. | `handlers.task.mark_gap` | — *(no CLI; tester→coder coverage-gap handoff is agent-to-agent; operators don't need it)* | -Total: **28 tools** across 5 namespaces (`sdlc`, `brc`, `phase`, -`progress`, `task`) — 18 iter-1 + 9 iter-2 (#1917) = 27, -then −2 in #2211 (`wait_for_event` + `wait_loop` removed; long-poll -waits go through `egg-orch message wait` / `wait-loop` via Bash), then -+1 in #2338 (`resolve_obligation`), then +2 in #2529 -(`check_file_restriction` + `report_impasse` — runtime escape -hatch). Covers the BRC consensus loop, -HITL (decisions + feedback + answers), phase context + completion, -progress signals + overseer alerts + status queries, and task completion -+ commits + notes + coverage-gaps — every -verb a pipeline agent issues on the hot path. The count and the namespace -set (`{sdlc, brc, phase, progress, task}`) are asserted by -`tests/sandbox/egg_agent_tools/test_server.py::TestToolRegistry` so the -prose numbers in this doc cannot drift silently. +### `mcp__confluence__*` — Confluence reads (gateway-backed, #2994) + +Mirror of the `sandbox/scripts/confluence` bash wrapper, one verb per +gateway route. **Read-only**, space-allowlisted, private-mode-gated — +all enforced at the gateway. snake_case args translate to the gateway's +camelCase body. No `egg-*` CLI counterpart (the analog is the bash +wrapper), so every registration is `cli_command=None`. + +| Tool | Purpose | Handler | Gateway route | +|------|---------|---------|---------------| +| `mcp__confluence__page_get` | Fetch a page by numeric `page_id`. Optional `body_format` (`['storage']` default, `['atlas_doc_format']`, `['view']`) and `expand`. | `handlers.confluence.confluence_page_get` | `POST /api/v1/confluence/page/get` | +| `mcp__confluence__page_descendants` | List a page's descendants. `depth` bounds the walk; `limit`+`cursor` paginate. | `handlers.confluence.confluence_page_descendants` | `POST /api/v1/confluence/page/descendants` | +| `mcp__confluence__page_footer_comments` | Footer comments on a page; `include_replies` inlines threads. | `handlers.confluence.confluence_page_footer_comments` | `POST /api/v1/confluence/page/footer-comments` | +| `mcp__confluence__page_inline_comments` | Inline comments on a page (gateway falls back to v1 if v2 404s). | `handlers.confluence.confluence_page_inline_comments` | `POST /api/v1/confluence/page/inline-comments` | +| `mcp__confluence__space_pages` | List pages in an allowlisted space. | `handlers.confluence.confluence_space_pages` | `POST /api/v1/confluence/space/pages` | +| `mcp__confluence__space_list` | List the spaces visible to the agent (filtered to the allowlist). **Use this to discover readable spaces — don't guess keys** (the #2994 motivating failure). | `handlers.confluence.confluence_space_list` | `POST /api/v1/confluence/space/list` | +| `mcp__confluence__search` | CQL query. Must statically scope to allowlisted spaces (an `OR` over `space` is denied). | `handlers.confluence.confluence_search` | `POST /api/v1/confluence/search` | +| `mcp__confluence__execute` | Raw read-only REST passthrough (GET-only; non-GET / denied paths → 403). | `handlers.confluence.confluence_execute` | `POST /api/v1/confluence/execute` | + +### `mcp__jira__*` — Jira reads + writes (gateway-backed, #2994) + +Mirror of the `sandbox/scripts/jira` bash wrapper, one verb per gateway +route. Reads plus the four dedicated write routes (`ticket_create`, +`ticket_edit`, `ticket_comment_add`, `link_create`); project-allowlisted +and private-mode-gated at the gateway. The operator-only `transition` +route is **not** mirrored (the bash wrapper doesn't surface it either). +No `egg-*` CLI counterpart, so every registration is `cli_command=None`. + +| Tool | Purpose | Handler | Gateway route | +|------|---------|---------|---------------| +| `mcp__jira__ticket_get` | Fetch a ticket by key (e.g. `ENG-123`); optional `fields`. | `handlers.jira.jira_ticket_get` | `POST /api/v1/jira/ticket/get` | +| `mcp__jira__ticket_comments` | Fetch a ticket's comments. | `handlers.jira.jira_ticket_comments` | `POST /api/v1/jira/ticket/comments` | +| `mcp__jira__ticket_remotelinks` | Fetch a ticket's remote links (surfaces PRs humans opened against it). | `handlers.jira.jira_ticket_remotelinks` | `POST /api/v1/jira/ticket/remotelinks` | +| `mcp__jira__search` | JQL search. Must statically scope to allowlisted projects (an `OR` over `project` is denied); `next_page_token` paginates. | `handlers.jira.jira_search` | `POST /api/v1/jira/search` | +| `mcp__jira__ticket_create` | Create a ticket (`project`, `issue_type`, `summary` required). State-machine effect: **creates a new issue**. `idempotency_key` makes a retry safe. | `handlers.jira.jira_ticket_create` | `POST /api/v1/jira/ticket/create` | +| `mcp__jira__ticket_edit` | Edit a ticket. `labels` (replace) is mutually exclusive with `add_labels`/`remove_labels` (incremental); `notify_users` defaults true to match the `sandbox/scripts/jira` wrapper (pass false to suppress notifications). State-machine effect: **mutates issue fields in place**. | `handlers.jira.jira_ticket_edit` | `POST /api/v1/jira/ticket/edit` | +| `mcp__jira__ticket_comment_add` | Add a comment (`idempotency_key` makes a retry safe). | `handlers.jira.jira_ticket_comment_add` | `POST /api/v1/jira/ticket/comment/add` | +| `mcp__jira__link_create` | Link two tickets (`link_type`, `inward_issue`, `outward_issue`). State-machine effect: **creates an issue link**. | `handlers.jira.jira_link_create` | `POST /api/v1/jira/issue-link/create` | +| `mcp__jira__execute` | Raw read-only REST passthrough (GET-only; non-GET / denied paths → 403). | `handlers.jira.jira_execute` | `POST /api/v1/jira/execute` | + +Total: **45 tools** across 7 namespaces (`sdlc`, `brc`, `phase`, +`progress`, `task`, `confluence`, `jira`) — 18 iter-1 + 12 iter-2 +(#1917) = 30, then −2 in #2211 (`wait_for_event` + `wait_loop` +removed; long-poll waits go through `egg-orch message wait` / +`wait-loop` via Bash), then +1 in #2338 (`resolve_obligation`), then +2 +in #2529 (`check_file_restriction` + `report_impasse` — runtime escape +hatch) = 31, then −3 in #2993 (checkpoint subsystem removed) = 28, +then +17 in #2994 (8 `mcp__confluence__*` + 9 `mcp__jira__*` +gateway-route mirrors) = 45. Covers the BRC consensus loop, HITL +(decisions + feedback + answers), phase context + completion, progress +signals + overseer alerts + status queries, task completion + commits + +notes + coverage-gaps, and the Confluence/Jira gateway routes — every +verb a pipeline agent issues on the hot path. The count (`45`) is +asserted by +`tests/sandbox/egg_agent_tools/test_server.py::TestToolRegistry::test_tool_count_registered` +and the namespace set (`{sdlc, brc, phase, progress, task, confluence, +jira}`) by `TestToolRegistry::test_namespace_set` so the prose numbers +in this doc cannot drift silently. ## Conventions @@ -248,6 +312,7 @@ verbs are: - `mcp__task__mark_gap` — no CLI; tester→coder coverage-gap handoff is agent-to-agent. - `mcp__sdlc__check_file_restriction` — no CLI; pattern matching is pure CPU and the registry ships in the sandbox image — a CLI shim would just re-import the same module (decision-13 rationale in `handlers/restrictions.py`). - `mcp__sdlc__report_impasse` — no CLI; structured runtime signal that lives inside agent-output JSON — a parallel CLI write path would just risk drift with the MCP one (decision-13 rationale in `handlers/restrictions.py`). +- `mcp__confluence__*` / `mcp__jira__*` (17 verbs, #2994) — no CLI *in the drift-test sense*: their human-facing analog is the **bash** `sandbox/scripts/{confluence,jira}` wrapper, not a Python `egg-*` argparse tree the drift gate can walk, so the registrations set `cli_command=None` and each handler docstring carries the `"no CLI"` rationale. They are gateway-route mirrors (`handlers/{confluence,jira}.py` → `/api/v1/{confluence,jira}/*`); the gateway, not these handlers, holds credentials and enforces policy. > **CLI surface added in #2908 (registration unchanged):** `mcp__brc__get_state`, `mcp__brc__list_blocking` (slice-1), `mcp__brc__read_peer_artifact`, and `mcp__brc__resolve_obligation` (slice-5) gained matching `egg-orch brc ` subcommands so the event-pump consensus wrapper (#2908 slices 1-2) can drive them from bash without an LLM round-trip. The MCP-side `ToolRegistration` entries in `sandbox/egg_agent_tools/tools/brc.py` still carry `cli_command=None`, so the drift gate continues to treat these four as no-CLI tools and their JSON schemas continue to be hand-authored in `schemas.py` (the bullets above stay accurate from the registration / drift-gate perspective). Promoting the registrations to `cli_command=("egg-orch", "brc", "")` so the schemas auto-derive from the argparse parsers is a follow-up — until then, treat the CLI subcommands as thin shell wrappers over the same handlers, sharing the handler but not the schema source. A fifth net-new `brc next-action` subcommand has no MCP counterpart by design — the wrapper consumes the derivation directly. See [Orchestrator CLI — BRC verb-level operations](orchestrator-cli.md#brc-verb-level-operations-egg-orch-brc). @@ -282,10 +347,12 @@ Each tool may supply a per-tool override dict for cases where argparse help is insufficient (e.g. richer descriptions or tighter enum constraints). Tools whose `ToolRegistration` declares `cli_command=None` — `phase_get_context`, `phase_get_assigned_tasks`, `check_hitl_answers`, -`task_mark_gap`, and the four `mcp__brc__*` verbs covered above +`task_mark_gap`, the four `mcp__brc__*` verbs covered above (`brc_get_state`, `brc_list_blocking`, `brc_read_peer_artifact`, -`brc_resolve_obligation`) — declare their JSON schema directly in -`schemas.py` (or alongside the `@tool` definition); the +`brc_resolve_obligation`), and the 17 `mcp__confluence__*` / +`mcp__jira__*` verbs (#2994) — declare their JSON schema directly in +`schemas.py` (or, for the Atlassian verbs, inline alongside the `@tool` +definition in `tools/{confluence,jira}.py`); the `derive_schema_from_argparse` path is skipped because the registration has no argparse subparser bound to it. The slice-1 / slice-5 of [#2908](https://github.com/jwbron/egg/issues/2908) `egg-orch brc ` @@ -318,10 +385,10 @@ namespace appears as `mcp____` in the nudge, and `mcp____` substring in the nudge corresponds to a registered namespace (extras in either direction fail CI). The companion `TestToolRegistry::test_tool_count_registered` and -`test_namespace_set` pin `len(TOOL_REGISTRY)` and +`test_namespace_set` pin `len(TOOL_REGISTRY) == 45` and `set(TOOL_NAMESPACES.keys()) == {"sdlc", "brc", "phase", "progress", -"task"}` so a future iteration cannot drift the prose -counts in this file silently. +"task", "confluence", "jira"}` so a future iteration cannot drift the +prose counts in this file silently. **The source of truth is `sandbox/egg_agent_tools/server.py::_render_nudge()`.** This doc does NOT embed a copy of the rendered string — the template @@ -339,7 +406,7 @@ keeps both sides honest. The nudge points agents at `mcp____*`, which is the literal name Claude sees in `tool_use` blocks — the per-namespace server split (one SDK MCP server per `sdlc` / `brc` / `phase` / -`progress` / `task` key) makes the composed +`progress` / `task` / `confluence` / `jira` key) makes the composed `mcp____` resolve directly to the semantic name the nudge advertises. No mental prefix-prepending required. @@ -466,9 +533,10 @@ complete shell CLI surface. - **`EGG_MCP_TOOLS` flag removal (decision-9 of #1917):** Kept for iter-2 burn-in; removal is a third follow-up. - **Timeouts:** The SDK's default 60 s MCP-tool timeout is sufficient - for all verbs (none are long-running). Pagination (decision-12 - of #1917) keeps `read_peer_artifact` page sizes well under the - limit. If a future + for all 45 verbs (none are long-running; the `mcp__confluence__*` / + `mcp__jira__*` verbs are single gateway→Atlassian round-trips well + under the cap). Pagination (decision-12 of #1917) keeps + `read_peer_artifact` page sizes well under the limit. If a future tool needs to exceed 60 s, it must be restructured as a start/poll/complete triplet — handled in a follow-up. - **Observability:** Native SDK `tool_use` naming is enough today — @@ -490,10 +558,10 @@ SDK release notes rather than silently breaking every sandbox. | Test | Purpose | |------|---------| -| `tests/sandbox/egg_agent_tools/test_handlers_*.py` | Unit tests for each handler (happy-path, missing-arg, 5xx gateway → `GatewayError`). | +| `tests/sandbox/egg_agent_tools/test_handlers_*.py` | Unit tests for each handler (happy-path, missing-arg, 5xx gateway → `GatewayError`). Includes `test_handlers_confluence.py` / `test_handlers_jira.py` (#2994): snake→camel body translation, required-field validation, list/CSV normalisation, and the `gateway_data_request` unwrap. | | `tests/sandbox/egg_agent_tools/handlers/test_*.py` | Per-handler unit tests for the iter-2 verbs (`show_contract`, `add_commit`, `update_notes`, `complete_phase`, `verify_criterion`, `read_peer_artifact`, `overseer_alert`, `query_status`, `mark_gap`). | | `tests/sandbox/egg_agent_tools/test_tools.py` | `@tool` wrappers (JSON-serialised success; `is_error=True` structured block on handler exception). | -| `tests/sandbox/egg_agent_tools/test_server.py` | `build_sandbox_mcp_server` registers all tools; `SYSTEM_PROMPT_NUDGE` symmetric drift test; derived-count assertions (`len(TOOL_REGISTRY)` and the namespace set). | +| `tests/sandbox/egg_agent_tools/test_server.py` | `build_sandbox_mcp_server` registers all 45 tools; `SYSTEM_PROMPT_NUDGE` symmetric drift test; derived-count assertions (`len(TOOL_REGISTRY) == 45` and the 7-namespace set). | | `tests/sandbox/egg_agent_tools/test_schemas.py` | `derive_schema_from_argparse` correctness + override merge. | | `tests/sandbox/egg_agent_tools/test_sdk_surface.py` | SDK import smoke (fails loud on incompatible SDK upgrade). | | `tests/sandbox/egg_agent_tools/test_full_tool_registry.py` | Integration test: loads `TOOL_LIST` via `create_sdk_mcp_server`; asserts no registration errors and that completion/mutation verbs (`task_complete`, `phase__complete_phase`, `task__add_commit`, `sdlc__verify_criterion`) name the state-machine effect in their description. | @@ -523,3 +591,8 @@ SDK release notes rather than silently breaking every sandbox. (12 additional verbs + rule-doc drift gate + decision-13 gate). - [#1955](https://github.com/jwbron/egg/issues/1955) — closed by iteration 2's `mcp__sdlc__show_contract` + state-machine writes. +- [#2994](https://github.com/jwbron/egg/issues/2994) — the + `mcp__confluence__*` / `mcp__jira__*` gateway-route mirrors + (discoverability). See also + [Confluence Wrapper](confluence-wrapper.md) and + [Jira Wrapper](jira-wrapper.md) for the gateway-side policy surface. diff --git a/docs/reference/confluence-wrapper.md b/docs/reference/confluence-wrapper.md index f100142298..fa902b459e 100644 --- a/docs/reference/confluence-wrapper.md +++ b/docs/reference/confluence-wrapper.md @@ -4,6 +4,8 @@ > Gateway REST surface that gives sandboxed agents **read-only** access to Confluence. Mirrors the `/api/v1/jira/*` pattern from [#1556](https://github.com/jwbron/egg/issues/1556): Atlassian credentials live in the gateway, the sandbox posts session-authenticated JSON, and every call is funneled through a private-mode gate, a **space allowlist**, a verb allowlist, and structured audit logs. +> **Two front-ends, one route surface.** Sandbox agents reach these routes via either the `mcp__confluence__*` MCP tools ([#2994](https://github.com/jwbron/egg/issues/2994)) — preferred, because they appear in the agent's tool manifest every turn — or the `sandbox/scripts/confluence` bash wrapper. Both POST the identical bodies documented below; the MCP layer holds no credentials and adds no capability (it's a presentation layer over these routes). See [Agent MCP Tools → `mcp__confluence__*`](agent-tools.md#mcp__confluence__--confluence-reads-gateway-backed-2994). This doc is the source of truth for the **route surface and policy**; the MCP tools translate snake_case args (`page_id`, `space_key`, `cql`, `body_format`) to the camelCase bodies below. + v1 is read-only — eight `POST /api/v1/confluence/*` routes covering page reads, descendants, footer/inline comments, space listings, CQL search, and a regex-allowlisted GET-only `/execute` escape hatch. Write verbs (`page/create`, `page/update`, `comment/create`) are scoped as a follow-up and drop in as three additional narrow routes under the same decorators and policy plumbing — no re-architecting. Attachments, restrictions, permissions, space-admin verbs, user enumeration, and `PUT` / `PATCH` / `DELETE` methods are **permanently out of scope** and are enforced at the path validator. The wrapper is a **v2-first hybrid** with transparent v1 fallbacks for two known v2 quirks: diff --git a/docs/reference/jira-wrapper.md b/docs/reference/jira-wrapper.md index 6bfc30ee2c..d0561eadb3 100644 --- a/docs/reference/jira-wrapper.md +++ b/docs/reference/jira-wrapper.md @@ -2,6 +2,8 @@ > Gateway REST surface that gives sandboxed agents bounded access to Jira. Mirrors the `/api/v1/gh/*` pattern: Atlassian credentials live in the gateway, the sandbox posts session-authenticated JSON, and every call is funneled through a private-mode gate, a project allowlist, and structured audit logs. +> **Two front-ends, one route surface.** Sandbox agents reach these routes via either the `mcp__jira__*` MCP tools ([#2994](https://github.com/jwbron/egg/issues/2994)) — preferred, because they appear in the agent's tool manifest every turn — or the `sandbox/scripts/jira` bash wrapper. Both POST the identical bodies documented below; the MCP layer holds no credentials and adds no capability (it's a presentation layer over these routes). The operator-only `ticket/transition` route is **not** exposed via MCP (the bash wrapper doesn't surface it either). See [Agent MCP Tools → `mcp__jira__*`](agent-tools.md#mcp__jira__--jira-reads--writes-gateway-backed-2994). This doc is the source of truth for the **route surface and policy**; the MCP tools translate snake_case args (`ticket`, `jql`, `issue_type`, `add_labels`) to the camelCase bodies below. + The original v1 surface ([#1556](https://github.com/jwbron/egg/issues/1556)) was read-only. The bounded write extension ([#1924](https://github.com/jwbron/egg/issues/1924)) adds four narrow write routes — `ticket/create`, `ticket/edit`, `ticket/comment/add`, `issue-link/create` — under the same decorators and policy plumbing. All write verbs share the read verbs' private-mode + project-allowlist + audit chain; see [Write verbs](#write-verbs) for the per-route body schema, ADF wrapping rules, idempotency-key semantics, and the audit-log redaction contract for write payloads. Transitions, worklogs, attachments, watchers, deletions, and `DELETE` methods are **permanently out of scope** and are enforced at the path validator. `PUT` / `PATCH` are blocked from the `/execute` passthrough but used internally by `JiraClient.edit_issue` against `PUT /rest/api/3/issue/{key}` (the validator is bypassed for hardcoded write methods). ## Endpoint surface @@ -457,7 +459,7 @@ jira link create \ Mixing two body-source flags returns a non-zero exit with a usage error before the gateway is called. Likewise for `--labels` vs `--add-labels` / `--remove-labels` on `ticket edit`. -**`notifyUsers` default differs between the wrapper and the HTTP route.** The HTTP route (`/api/v1/jira/ticket/edit`) defaults `notifyUsers=false` (decision-5 — quiet update). The `jira ticket edit` wrapper inverts that and defaults to `--notify` (sends `notifyUsers=true`) so the CLI matches Atlassian's UI behavior. Pass `--no-notify` to suppress notifications. +**`notifyUsers` default differs between the front-end wrappers and the HTTP route.** The HTTP route (`/api/v1/jira/ticket/edit`) defaults `notifyUsers=false` (decision-5 — quiet update). The `jira ticket edit` bash wrapper and the `mcp__jira__ticket_edit` MCP tool both invert that and default to notify-on (sends `notifyUsers=true`) so the front-ends match Atlassian's UI behavior. Pass `--no-notify` (bash) or `notify_users=false` (MCP) to suppress notifications. `jira help` (and `jira --help`) lists all eight subcommands (`ticket get | comments | create | edit`, `ticket comment add`, `search`, `execute`, `link create`). diff --git a/sandbox/agent-config/rules/environment.md b/sandbox/agent-config/rules/environment.md index 4209456ba3..cc4ec2a8eb 100644 --- a/sandbox/agent-config/rules/environment.md +++ b/sandbox/agent-config/rules/environment.md @@ -13,7 +13,7 @@ GitHub access MUST go through the gateway sidecar (not the proxy) for policy enf | Variable | Default | Purpose | |----------|---------|---------| -| `EGG_MCP_TOOLS` | unset (on) | **On by default since #1942.** Registers the in-process SDK MCP tool surface (5 namespaces: `mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`) on `ClaudeAgentOptions.mcp_servers` and appends a bootstrap paragraph to the system prompt. Prefer these tools over Bash-ing `egg-contract` / `egg-orch`. Set to `false` / `0` / `no` / `off` to opt out; the code path is then byte-identical to pre-#1765 behaviour. See `../../../docs/reference/agent-tools.md`. | +| `EGG_MCP_TOOLS` | unset (on) | **On by default since #1942.** Registers the in-process SDK MCP tool surface (45 verbs across 7 namespaces: `mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`, `mcp__confluence__*`, `mcp__jira__*`) on `ClaudeAgentOptions.mcp_servers` and appends a bootstrap paragraph to the system prompt. Prefer these tools over Bash-ing `egg-contract` / `egg-orch`. Set to `false` / `0` / `no` / `off` to opt out; the code path is then byte-identical to pre-#1765 behaviour. See `../../../docs/reference/agent-tools.md`. | ## Capabilities @@ -32,9 +32,11 @@ All git/gh operations routed through gateway. Key restrictions: If push fails: check `git remote -v` is HTTPS, check `curl http://egg-gateway:9848/api/v1/health`, verify branch is egg-owned. -### Jira Wrapper (`jira`) +### Jira (`mcp__jira__*` tools / `jira` wrapper) -The `sandbox/scripts/jira` wrapper is the only way for the sandbox to reach Jira — it POSTs to the gateway's `/api/v1/jira/*` routes with `Authorization: Bearer $EGG_SESSION_TOKEN` and Atlassian credentials never enter the sandbox. **Private network mode only**: in public mode every Jira call returns 403 `private_mode_required` before any upstream request. +The sandbox reaches Jira through the gateway's `/api/v1/jira/*` routes (`Authorization: Bearer $EGG_SESSION_TOKEN`; Atlassian credentials never enter the sandbox). **Private network mode only**: in public mode every Jira call returns 403 `private_mode_required` before any upstream request. + +**Reach for the `mcp__jira__*` MCP tools first** (`mcp__jira__ticket_get`, `mcp__jira__search`, `mcp__jira__ticket_comments`, `mcp__jira__ticket_create`, `mcp__jira__ticket_edit`, `mcp__jira__ticket_comment_add`, `mcp__jira__link_create`, `mcp__jira__ticket_remotelinks`, `mcp__jira__execute`). They appear in your tool manifest every turn (no need to recall this doc), take snake_case args (`ticket`, `jql`, `max_results`, `fields`, …), and hit the same gateway routes under the same policy. The `sandbox/scripts/jira` shell wrapper below remains available for ad-hoc debugging and is the source of truth for the verb/route surface (#2994). | Verb | Gateway route | |------|---------------| @@ -75,9 +77,11 @@ jira search 'project = ENG OR project = SEC' **Hard limits (always denied):** `transitions`, `worklog`, `attachments`, `watchers`, HTTP `DELETE` (no exposed surface), path traversal (`..`), duplicate slashes, non-ASCII keys. The `/execute` passthrough is GET-only (`PUT` / `PATCH` return 403 there); the four write routes (`ticket/create`, `ticket/edit`, `ticket/comment/add`, `issue-link/create`) use their own dedicated paths and are the only write surface. See [Jira Wrapper Reference](../../../docs/reference/jira-wrapper.md) for the full endpoint surface, write verb body schemas, idempotency keys, and the `not_found` envelope. -### Confluence Wrapper (`confluence`) +### Confluence (`mcp__confluence__*` tools / `confluence` wrapper) + +The sandbox reaches Confluence through the gateway's `/api/v1/confluence/*` routes (`Authorization: Bearer $EGG_SESSION_TOKEN`; Atlassian credentials never enter the sandbox). **Private network mode only**: in public mode every Confluence call returns 403 `private_mode_required` before any upstream request. -The `sandbox/scripts/confluence` wrapper is the only way for the sandbox to reach Confluence — it POSTs to the gateway's `/api/v1/confluence/*` routes with `Authorization: Bearer $EGG_SESSION_TOKEN` and Atlassian credentials never enter the sandbox. **Private network mode only**: in public mode every Confluence call returns 403 `private_mode_required` before any upstream request. +**Reach for the `mcp__confluence__*` MCP tools first** (`mcp__confluence__page_get`, `mcp__confluence__search`, `mcp__confluence__space_list`, `mcp__confluence__space_pages`, `mcp__confluence__page_descendants`, `mcp__confluence__page_footer_comments`, `mcp__confluence__page_inline_comments`, `mcp__confluence__execute`). They appear in your tool manifest every turn, so **don't guess which spaces exist — call `mcp__confluence__space_list`**, which returns the allowlisted set (the failure #2994 fixes). They take snake_case args (`page_id`, `space_key`, `cql`, `body_format`, …) and hit the same gateway routes under the same policy. The `sandbox/scripts/confluence` shell wrapper below remains available for ad-hoc debugging and is the source of truth for the verb/route surface (#2994). | Verb | Gateway route | |------|---------------| diff --git a/sandbox/egg_agent_tools/handlers/_gateway.py b/sandbox/egg_agent_tools/handlers/_gateway.py index 044d0d8330..5e563d7f88 100644 --- a/sandbox/egg_agent_tools/handlers/_gateway.py +++ b/sandbox/egg_agent_tools/handlers/_gateway.py @@ -232,6 +232,40 @@ def gateway_request( raise GatewayError("Request to gateway timed out") from exc +def gateway_data_request( + endpoint: str, + *, + body: dict[str, Any] | None = None, + timeout: int = 30, +) -> Any: + """POST ``body`` to a gateway route and return the unwrapped ``data``. + + The Atlassian gateway routes (``/api/v1/{confluence,jira}/*``) all + answer with the standard ``{success, message, data, details}`` + envelope (``make_success`` / ``make_error`` in ``gateway/gateway.py``) + and use HTTP status codes for failures. :func:`gateway_request` + already raises :class:`GatewayError` on any non-2xx response, so on + the happy path we simply hand the caller the upstream ``data`` + payload — exactly what the ``confluence`` / ``jira`` shell wrappers + print on success (their ``call_gateway`` emits ``data`` as JSON). + + The ``success`` re-check is defence-in-depth for the unlikely case of + a 2xx body that still flags ``success: false``; the not-found envelope + is *not* such a case (the gateway returns ``success: true`` with + ``data.status == "not_found"``), so it flows through untouched. + """ + result = gateway_request(endpoint, method="POST", data=body or {}, timeout=timeout) + if isinstance(result, dict) and result.get("success") is False: + details = result.get("details") + raise GatewayError( + result.get("message", "gateway request failed"), + details=details if isinstance(details, dict) else None, + ) + if isinstance(result, dict): + return result.get("data", result) + return result + + def orchestrator_request( endpoint: str, *, diff --git a/sandbox/egg_agent_tools/handlers/confluence.py b/sandbox/egg_agent_tools/handlers/confluence.py new file mode 100644 index 0000000000..f6b7abe997 --- /dev/null +++ b/sandbox/egg_agent_tools/handlers/confluence.py @@ -0,0 +1,198 @@ +"""Confluence handlers — gateway-backed reads for sandbox agents (#2994). + +These mirror the verbs in the ``sandbox/scripts/confluence`` shell +wrapper one-for-one, POSTing to the same ``/api/v1/confluence/*`` gateway +routes with the session token :mod:`egg_agent_tools.handlers._gateway` +already resolves. They hold **no Atlassian credentials** and add **no +new capability** — the gateway still enforces the space allowlist, +read-only policy, CQL scope extraction, response redaction, and the +private-network-mode gate. This is a presentation layer that makes the +routes discoverable as ``mcp__confluence__*`` tools (the bash wrapper is +prose an agent has to recall; an MCP tool is in the manifest every turn). + +Each handler accepts snake_case request keys (the convention every other +egg MCP tool uses) and translates them to the camelCase field names the +gateway expects. None of these verbs has a Python ``egg-*`` CLI +counterpart the MCP↔CLI drift test can walk — the human-facing analog is +the bash ``confluence`` wrapper, not an argparse parser — so every +registration sets ``cli_command=None`` and each docstring records the +"no CLI" rationale required by the decision-13 drift gate. +""" + +from __future__ import annotations + +from typing import Any + +from egg_agent_tools.handlers._gateway import gateway_data_request +from egg_agent_tools.handlers.errors import HandlerError + + +def _as_str_list(value: Any) -> list[str] | None: + """Normalise a list-or-CSV-string into a clean ``list[str]`` (or None). + + The agent-facing schema asks for an array, but a model occasionally + passes the gateway's comma-separated form; accept both so a stray + ``"storage,view"`` doesn't 400 at the gateway. + """ + if value is None: + return None + if isinstance(value, str): + items = [v.strip() for v in value.split(",")] + elif isinstance(value, (list, tuple)): + items = [str(v).strip() for v in value] + else: + raise HandlerError(f"expected a string or list of strings, got {type(value).__name__}") + cleaned = [v for v in items if v] + return cleaned or None + + +def _require_str(req: dict[str, Any], key: str, label: str) -> str: + value = req.get(key) + if not isinstance(value, str) or not value.strip(): + raise HandlerError(f"{label} is required (pass '{key}')") + return value.strip() + + +def confluence_page_get(req: dict[str, Any]) -> Any: + """Fetch a Confluence page by numeric ``page_id`` (no CLI counterpart). + + Mirrors ``confluence page get`` → ``POST /api/v1/confluence/page/get``. + Optional ``body_format`` (list, default ``storage`` server-side) and + ``expand`` select the rendition and extra fields. + """ + body: dict[str, Any] = {"pageId": _require_str(req, "page_id", "page_id")} + body_format = _as_str_list(req.get("body_format")) + if body_format: + body["bodyFormat"] = body_format + expand = _as_str_list(req.get("expand")) + if expand: + body["expand"] = expand + return gateway_data_request("/api/v1/confluence/page/get", body=body) + + +def confluence_page_descendants(req: dict[str, Any]) -> Any: + """List the descendants of a Confluence page (no CLI counterpart). + + Mirrors ``confluence page descendants`` → + ``POST /api/v1/confluence/page/descendants``. Paginated via ``limit`` + + opaque ``cursor``; ``depth`` bounds the tree walk. + """ + body: dict[str, Any] = {"pageId": _require_str(req, "page_id", "page_id")} + if req.get("depth") is not None: + body["depth"] = req["depth"] + if req.get("limit") is not None: + body["limit"] = int(req["limit"]) + if req.get("cursor"): + body["cursor"] = req["cursor"] + return gateway_data_request("/api/v1/confluence/page/descendants", body=body) + + +def confluence_page_footer_comments(req: dict[str, Any]) -> Any: + """Fetch footer comments on a Confluence page (no CLI counterpart). + + Mirrors ``confluence page footer-comments`` → + ``POST /api/v1/confluence/page/footer-comments``. Set + ``include_replies`` to inline threaded replies. + """ + body: dict[str, Any] = { + "pageId": _require_str(req, "page_id", "page_id"), + "includeReplies": bool(req.get("include_replies", False)), + } + body_format = _as_str_list(req.get("body_format")) + if body_format: + body["bodyFormat"] = body_format + if req.get("limit") is not None: + body["limit"] = int(req["limit"]) + if req.get("cursor"): + body["cursor"] = req["cursor"] + return gateway_data_request("/api/v1/confluence/page/footer-comments", body=body) + + +def confluence_page_inline_comments(req: dict[str, Any]) -> Any: + """Fetch inline comments on a Confluence page (no CLI counterpart). + + Mirrors ``confluence page inline-comments`` → + ``POST /api/v1/confluence/page/inline-comments``. The gateway + transparently retries against the v1 API when v2 returns 404 and + flags ``used_fallback`` on the response. + """ + body: dict[str, Any] = {"pageId": _require_str(req, "page_id", "page_id")} + body_format = _as_str_list(req.get("body_format")) + if body_format: + body["bodyFormat"] = body_format + if req.get("limit") is not None: + body["limit"] = int(req["limit"]) + if req.get("cursor"): + body["cursor"] = req["cursor"] + return gateway_data_request("/api/v1/confluence/page/inline-comments", body=body) + + +def confluence_space_pages(req: dict[str, Any]) -> Any: + """List pages in a Confluence space (no CLI counterpart). + + Mirrors ``confluence space pages`` → + ``POST /api/v1/confluence/space/pages``. The space must be on the + operator's allowlist or the gateway returns 403. + """ + body: dict[str, Any] = {"spaceKey": _require_str(req, "space_key", "space_key")} + if req.get("limit") is not None: + body["limit"] = int(req["limit"]) + if req.get("cursor"): + body["cursor"] = req["cursor"] + body_format = _as_str_list(req.get("body_format")) + if body_format: + body["bodyFormat"] = body_format + return gateway_data_request("/api/v1/confluence/space/pages", body=body) + + +def confluence_space_list(req: dict[str, Any]) -> Any: + """List the Confluence spaces visible to the agent (no CLI counterpart). + + Mirrors ``confluence space list`` → + ``POST /api/v1/confluence/space/list``. The response is filtered to + the operator's allowlist — this is the verb that answers "which + spaces can I read?" without guessing (the #2994 motivating failure). + """ + body: dict[str, Any] = {} + if req.get("limit") is not None: + body["limit"] = int(req["limit"]) + if req.get("cursor"): + body["cursor"] = req["cursor"] + return gateway_data_request("/api/v1/confluence/space/list", body=body) + + +def confluence_search(req: dict[str, Any]) -> Any: + """Run a CQL query (no CLI counterpart). + + Mirrors ``confluence search`` → ``POST /api/v1/confluence/search``. + The CQL must statically scope to allowlisted spaces; the gateway's + scope extractor denies on ambiguity (any ``OR`` over ``space``). + """ + body: dict[str, Any] = {"cql": _require_str(req, "cql", "cql")} + if req.get("limit") is not None: + body["limit"] = int(req["limit"]) + if req.get("cursor"): + body["cursor"] = req["cursor"] + return gateway_data_request("/api/v1/confluence/search", body=body) + + +def confluence_execute(req: dict[str, Any]) -> Any: + """Raw read-only Confluence REST passthrough (no CLI counterpart). + + Mirrors ``confluence execute`` → ``POST /api/v1/confluence/execute``. + GET-only escape hatch for routes without a dedicated verb; the + gateway rejects non-GET methods and denied paths with 403. + """ + body: dict[str, Any] = { + "method": _require_str(req, "method", "method"), + "path": _require_str(req, "path", "path"), + } + query = req.get("query") + if query is not None: + if not isinstance(query, dict): + raise HandlerError("'query' must be an object of string key/value pairs") + body["query"] = query + request_body = req.get("body") + if request_body is not None: + body["body"] = request_body + return gateway_data_request("/api/v1/confluence/execute", body=body) diff --git a/sandbox/egg_agent_tools/handlers/jira.py b/sandbox/egg_agent_tools/handlers/jira.py new file mode 100644 index 0000000000..daa7ba6489 --- /dev/null +++ b/sandbox/egg_agent_tools/handlers/jira.py @@ -0,0 +1,225 @@ +"""Jira handlers — gateway-backed reads + writes for sandbox agents (#2994). + +These mirror the verbs in the ``sandbox/scripts/jira`` shell wrapper +one-for-one, POSTing to the same ``/api/v1/jira/*`` gateway routes with +the session token :mod:`egg_agent_tools.handlers._gateway` already +resolves. They hold **no Atlassian credentials** and add **no new +capability** — the gateway still enforces the project allowlist, the +read-only vs. four-write-route split, JQL scope extraction, and the +private-network-mode gate. This is a presentation layer that makes the +routes discoverable as ``mcp__jira__*`` tools. + +The ``transition`` route the gateway exposes is deliberately *not* +mirrored here: the bash wrapper does not surface it either (it is an +operator-only, separately-authorised path), and #2994 scopes the MCP +surface to the wrapper's verbs. + +Each handler accepts snake_case request keys and translates them to the +camelCase field names the gateway expects. None of these verbs has a +Python ``egg-*`` CLI counterpart the MCP↔CLI drift test can walk — the +human-facing analog is the bash ``jira`` wrapper, not an argparse parser +— so every registration sets ``cli_command=None`` and each docstring +records the "no CLI" rationale required by the decision-13 drift gate. +""" + +from __future__ import annotations + +from typing import Any + +from egg_agent_tools.handlers._gateway import gateway_data_request +from egg_agent_tools.handlers.errors import HandlerError + + +def _as_str_list(value: Any) -> list[str] | None: + """Normalise a list-or-CSV-string into a clean ``list[str]`` (or None).""" + if value is None: + return None + if isinstance(value, str): + items = [v.strip() for v in value.split(",")] + elif isinstance(value, (list, tuple)): + items = [str(v).strip() for v in value] + else: + raise HandlerError(f"expected a string or list of strings, got {type(value).__name__}") + cleaned = [v for v in items if v] + return cleaned or None + + +def _require_str(req: dict[str, Any], key: str, label: str) -> str: + value = req.get(key) + if not isinstance(value, str) or not value.strip(): + raise HandlerError(f"{label} is required (pass '{key}')") + return value.strip() + + +def jira_ticket_get(req: dict[str, Any]) -> Any: + """Fetch a single Jira ticket by key (no CLI counterpart). + + Mirrors ``jira ticket get`` → ``POST /api/v1/jira/ticket/get``. + Optional ``fields`` narrows the returned field set; omitting it + returns Atlassian's default set plus rendered body. + """ + body: dict[str, Any] = {"ticket": _require_str(req, "ticket", "ticket key")} + fields = _as_str_list(req.get("fields")) + if fields: + body["fields"] = fields + return gateway_data_request("/api/v1/jira/ticket/get", body=body) + + +def jira_ticket_comments(req: dict[str, Any]) -> Any: + """Fetch comments on a Jira ticket (no CLI counterpart). + + Mirrors ``jira ticket comments`` → + ``POST /api/v1/jira/ticket/comments``. + """ + body = {"ticket": _require_str(req, "ticket", "ticket key")} + return gateway_data_request("/api/v1/jira/ticket/comments", body=body) + + +def jira_ticket_remotelinks(req: dict[str, Any]) -> Any: + """Fetch the remote links on a Jira ticket (no CLI counterpart). + + Mirrors ``jira ticket remotelinks`` → + ``POST /api/v1/jira/ticket/remotelinks``. Surfaces PRs humans opened + against a child ticket so a reassess sweep can treat it as in-flight. + """ + body = {"ticket": _require_str(req, "ticket", "ticket key")} + return gateway_data_request("/api/v1/jira/ticket/remotelinks", body=body) + + +def jira_search(req: dict[str, Any]) -> Any: + """Search issues with JQL (no CLI counterpart). + + Mirrors ``jira search`` → ``POST /api/v1/jira/search``. The JQL must + statically scope to allowlisted projects; the gateway denies on + ambiguity (any ``OR`` over ``project``). Paginate with + ``next_page_token``. + """ + body: dict[str, Any] = {"jql": _require_str(req, "jql", "jql")} + if req.get("max_results") is not None: + body["maxResults"] = int(req["max_results"]) + fields = _as_str_list(req.get("fields")) + if fields: + body["fields"] = fields + if req.get("next_page_token"): + body["nextPageToken"] = req["next_page_token"] + return gateway_data_request("/api/v1/jira/search", body=body) + + +def jira_ticket_create(req: dict[str, Any]) -> Any: + """Create a new Jira ticket (no CLI counterpart). + + Mirrors ``jira ticket create`` → + ``POST /api/v1/jira/ticket/create``. ``project``, ``issue_type``, + and ``summary`` are required; the gateway enforces the project + allowlist and private-network mode. Pass ``idempotency_key`` to make + a retried create safe. + """ + body: dict[str, Any] = { + "project": _require_str(req, "project", "project key"), + "issuetype": _require_str(req, "issue_type", "issue_type"), + "summary": _require_str(req, "summary", "summary"), + } + if "description" in req and req["description"] is not None: + body["description"] = req["description"] + labels = _as_str_list(req.get("labels")) + if labels: + body["labels"] = labels + if req.get("parent"): + body["parent"] = req["parent"] + if req.get("epic_link"): + body["epicLink"] = req["epic_link"] + if req.get("idempotency_key"): + body["idempotencyKey"] = req["idempotency_key"] + return gateway_data_request("/api/v1/jira/ticket/create", body=body) + + +def jira_ticket_edit(req: dict[str, Any]) -> Any: + """Edit an existing Jira ticket (no CLI counterpart). + + Mirrors ``jira ticket edit`` → ``POST /api/v1/jira/ticket/edit``. + ``labels`` (replace) is mutually exclusive with + ``add_labels``/``remove_labels`` (incremental). ``notify_users`` + defaults to True to match the ``sandbox/scripts/jira`` wrapper, which + deliberately overrides the gateway's notify-off default so that a + planner-authored task referring to either front-end produces the same + observable side effects; pass ``notify_users=False`` to suppress + notifications (mirrors the bash ``--no-notify`` flag). + """ + body: dict[str, Any] = {"ticket": _require_str(req, "ticket", "ticket key")} + if "summary" in req and req["summary"] is not None: + body["summary"] = req["summary"] + if "description" in req and req["description"] is not None: + body["description"] = req["description"] + + labels = _as_str_list(req.get("labels")) + add_labels = _as_str_list(req.get("add_labels")) + remove_labels = _as_str_list(req.get("remove_labels")) + if labels is not None and (add_labels is not None or remove_labels is not None): + raise HandlerError( + "'labels' (replace) is mutually exclusive with " + "'add_labels'/'remove_labels' (incremental)" + ) + if labels is not None: + body["labels"] = labels + if add_labels is not None: + body["addLabels"] = add_labels + if remove_labels is not None: + body["removeLabels"] = remove_labels + + body["notifyUsers"] = bool(req.get("notify_users", True)) + return gateway_data_request("/api/v1/jira/ticket/edit", body=body) + + +def jira_ticket_comment_add(req: dict[str, Any]) -> Any: + """Add a comment to a Jira ticket (no CLI counterpart). + + Mirrors ``jira ticket comment add`` → + ``POST /api/v1/jira/ticket/comment/add``. Pass ``idempotency_key`` + to make a retried add safe. + """ + body: dict[str, Any] = { + "ticket": _require_str(req, "ticket", "ticket key"), + "body": _require_str(req, "body", "comment body"), + } + if req.get("idempotency_key"): + body["idempotencyKey"] = req["idempotency_key"] + return gateway_data_request("/api/v1/jira/ticket/comment/add", body=body) + + +def jira_link_create(req: dict[str, Any]) -> Any: + """Link two Jira tickets (no CLI counterpart). + + Mirrors ``jira link create`` → + ``POST /api/v1/jira/issue-link/create``. Both issues must be on + allowlisted projects. ``link_type`` is the Jira link name (e.g. + ``Blocks``); ``inward_issue``/``outward_issue`` are the two keys. + """ + body: dict[str, Any] = { + "type": _require_str(req, "link_type", "link_type"), + "inwardIssue": _require_str(req, "inward_issue", "inward_issue"), + "outwardIssue": _require_str(req, "outward_issue", "outward_issue"), + } + if "comment" in req and req["comment"] is not None: + body["comment"] = req["comment"] + if req.get("idempotency_key"): + body["idempotencyKey"] = req["idempotency_key"] + return gateway_data_request("/api/v1/jira/issue-link/create", body=body) + + +def jira_execute(req: dict[str, Any]) -> Any: + """Raw read-only Jira REST passthrough (no CLI counterpart). + + Mirrors ``jira execute`` → ``POST /api/v1/jira/execute``. GET-only + escape hatch for routes without a dedicated verb; the gateway rejects + non-GET methods and denied paths with 403. + """ + body: dict[str, Any] = { + "method": _require_str(req, "method", "method"), + "path": _require_str(req, "path", "path"), + } + query = req.get("query") + if query is not None: + if not isinstance(query, dict): + raise HandlerError("'query' must be an object of string key/value pairs") + body["query"] = query + return gateway_data_request("/api/v1/jira/execute", body=body) diff --git a/sandbox/egg_agent_tools/tools/__init__.py b/sandbox/egg_agent_tools/tools/__init__.py index 4d9e8ea3f7..670315dfde 100644 --- a/sandbox/egg_agent_tools/tools/__init__.py +++ b/sandbox/egg_agent_tools/tools/__init__.py @@ -19,6 +19,8 @@ from typing import Any from egg_agent_tools.tools import brc as _brc_tools +from egg_agent_tools.tools import confluence as _confluence_tools +from egg_agent_tools.tools import jira as _jira_tools from egg_agent_tools.tools import message as _message_tools from egg_agent_tools.tools import phase as _phase_tools from egg_agent_tools.tools import progress as _progress_tools @@ -34,6 +36,8 @@ def _register_all() -> None: for module in ( _sdlc_tools, _brc_tools, + _confluence_tools, + _jira_tools, _message_tools, _phase_tools, _progress_tools, @@ -76,6 +80,8 @@ def _group_by_namespace() -> dict[str, list[str]]: "overseer alerts, and pipeline status reads" ), "task": "link commits, update notes, mark a contract task complete, and record coverage gaps", + "confluence": "read pages, comments, spaces, and CQL search (gateway, read-only)", + "jira": "read/write tickets, comments, links, and JQL search (gateway)", } diff --git a/sandbox/egg_agent_tools/tools/confluence.py b/sandbox/egg_agent_tools/tools/confluence.py new file mode 100644 index 0000000000..b08dfe0416 --- /dev/null +++ b/sandbox/egg_agent_tools/tools/confluence.py @@ -0,0 +1,285 @@ +"""Confluence-namespace @tool wrappers (#2994). + +Exposes the gateway-backed ``confluence`` verbs as ``mcp__confluence__*`` +MCP tools so the routes are discoverable in the agent's tool manifest +every turn — instead of prose in ``environment.md`` an agent has to +recall. Every wrapper forwards to a handler in +``egg_agent_tools.handlers.confluence``; the gateway enforces all policy +(space allowlist, read-only, CQL scope, redaction, private-mode gate), +so this layer carries no credentials and adds no capability. + +The namespace key is ``confluence`` so the Claude-visible names are +``mcp__confluence__`` — intentionally matching the *host* MCP +namespace (which is not present in the sandbox), so planner-authored +task text that references ``mcp__confluence__*`` resolves to the +restricted sandbox tools. These verbs have no ``egg-*`` CLI counterpart +the drift test can walk, so every ``ToolRegistration`` sets +``cli_command=None`` (see #2994 and the handler module docstring). +""" + +from __future__ import annotations + +from typing import Any + +from egg_agent_tools.handlers import confluence as handlers +from egg_agent_tools.tools._common import invoke_handler +from egg_agent_tools.tools._registry import ToolRegistration +from egg_agent_tools.tools._tool_compat import tool + +NAMESPACE = "confluence" + +_BODY_FORMAT = { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Renditions to return, e.g. ['storage'] (default, XHTML-like), " + "['atlas_doc_format'] (ADF JSON), or ['view'] (rendered HTML)." + ), +} +_LIMIT = {"type": "integer", "description": "Page size for pagination."} +_CURSOR = {"type": "string", "description": "Opaque pagination cursor from a prior response."} + +_PAGE_GET_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "page_id": {"type": "string", "description": "Numeric Confluence pageId."}, + "body_format": _BODY_FORMAT, + "expand": { + "type": "array", + "items": {"type": "string"}, + "description": "Extra fields to expand (e.g. ['version','ancestors']).", + }, + }, + "required": ["page_id"], +} + +_PAGE_DESCENDANTS_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "page_id": {"type": "string", "description": "Numeric Confluence pageId."}, + "depth": { + "type": ["integer", "string"], + "description": "How deep to walk the descendant tree (e.g. 1 or 'all').", + }, + "limit": _LIMIT, + "cursor": _CURSOR, + }, + "required": ["page_id"], +} + +_PAGE_FOOTER_COMMENTS_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "page_id": {"type": "string", "description": "Numeric Confluence pageId."}, + "include_replies": { + "type": "boolean", + "description": "Inline threaded replies (default false).", + }, + "body_format": _BODY_FORMAT, + "limit": _LIMIT, + "cursor": _CURSOR, + }, + "required": ["page_id"], +} + +_PAGE_INLINE_COMMENTS_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "page_id": {"type": "string", "description": "Numeric Confluence pageId."}, + "body_format": _BODY_FORMAT, + "limit": _LIMIT, + "cursor": _CURSOR, + }, + "required": ["page_id"], +} + +_SPACE_PAGES_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "space_key": {"type": "string", "description": "Confluence space key (e.g. 'ENG')."}, + "limit": _LIMIT, + "cursor": _CURSOR, + "body_format": _BODY_FORMAT, + }, + "required": ["space_key"], +} + +_SPACE_LIST_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "limit": _LIMIT, + "cursor": _CURSOR, + }, +} + +_SEARCH_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "cql": { + "type": "string", + "description": ( + "CQL query. Must statically scope to allowlisted spaces " + "(e.g. \"space = ENG AND text ~ 'RFC'\"); an OR over `space` is denied." + ), + }, + "limit": _LIMIT, + "cursor": _CURSOR, + }, + "required": ["cql"], +} + +_EXECUTE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "method": {"type": "string", "description": "HTTP method — GET only (others 403)."}, + "path": {"type": "string", "description": "Confluence REST path (allowlisted)."}, + "query": {"type": "object", "description": "Query-string key/value pairs."}, + "body": {"type": "object", "description": "Request body (rarely needed for reads)."}, + }, + "required": ["method", "path"], +} + + +@tool( + "page_get", + "Fetch a Confluence page by numeric pageId via the gateway " + "(space-allowlisted, read-only). Use instead of guessing page content.", + _PAGE_GET_SCHEMA, +) +async def confluence_page_get(args: dict[str, Any]) -> dict[str, Any]: + # Wiki-heavy pages can cross 1 MB once `body.storage.value` plus + # expansions are inlined. Spill oversized payloads via the + # `spill=True` surface in ``tools/_common.py`` so the agent can + # Read/grep the full payload from disk. + return await invoke_handler(handlers.confluence_page_get, args, spill=True) + + +@tool( + "page_descendants", + "List the descendants of a Confluence page via the gateway. " + "Paginated via `limit` + opaque `cursor`.", + _PAGE_DESCENDANTS_SCHEMA, +) +async def confluence_page_descendants(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_page_descendants, args) + + +@tool( + "page_footer_comments", + "Fetch footer comments on a Confluence page via the gateway.", + _PAGE_FOOTER_COMMENTS_SCHEMA, +) +async def confluence_page_footer_comments(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_page_footer_comments, args) + + +@tool( + "page_inline_comments", + "Fetch inline comments on a Confluence page via the gateway " + "(transparently falls back to the v1 API if v2 returns 404).", + _PAGE_INLINE_COMMENTS_SCHEMA, +) +async def confluence_page_inline_comments(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_page_inline_comments, args) + + +@tool( + "space_pages", + "List pages in a Confluence space via the gateway " + "(space must be allowlisted). Paginated via `limit` + `cursor`.", + _SPACE_PAGES_SCHEMA, +) +async def confluence_space_pages(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_space_pages, args) + + +@tool( + "space_list", + "List the Confluence spaces visible to the agent via the gateway, " + "filtered to the operator's allowlist. Use this to discover which " + "spaces are readable — do NOT guess space keys.", + _SPACE_LIST_SCHEMA, +) +async def confluence_space_list(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_space_list, args) + + +@tool( + "search", + "Run a CQL query via the gateway. CQL must statically scope to " + "allowlisted spaces (an OR over `space` is denied). Paginated via " + "`limit` + `cursor`.", + _SEARCH_SCHEMA, +) +async def confluence_search(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_search, args) + + +@tool( + "execute", + "Raw read-only Confluence REST passthrough via the gateway (GET-only " + "escape hatch for routes without a dedicated verb; non-GET returns 403).", + _EXECUTE_SCHEMA, +) +async def confluence_execute(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.confluence_execute, args) + + +REGISTRATIONS: list[ToolRegistration] = [ + ToolRegistration( + name="mcp__confluence__page_get", + namespace=NAMESPACE, + handler=handlers.confluence_page_get, + sdk_tool=confluence_page_get, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__page_descendants", + namespace=NAMESPACE, + handler=handlers.confluence_page_descendants, + sdk_tool=confluence_page_descendants, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__page_footer_comments", + namespace=NAMESPACE, + handler=handlers.confluence_page_footer_comments, + sdk_tool=confluence_page_footer_comments, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__page_inline_comments", + namespace=NAMESPACE, + handler=handlers.confluence_page_inline_comments, + sdk_tool=confluence_page_inline_comments, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__space_pages", + namespace=NAMESPACE, + handler=handlers.confluence_space_pages, + sdk_tool=confluence_space_pages, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__space_list", + namespace=NAMESPACE, + handler=handlers.confluence_space_list, + sdk_tool=confluence_space_list, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__search", + namespace=NAMESPACE, + handler=handlers.confluence_search, + sdk_tool=confluence_search, + cli_command=None, + ), + ToolRegistration( + name="mcp__confluence__execute", + namespace=NAMESPACE, + handler=handlers.confluence_execute, + sdk_tool=confluence_execute, + cli_command=None, + ), +] diff --git a/sandbox/egg_agent_tools/tools/jira.py b/sandbox/egg_agent_tools/tools/jira.py new file mode 100644 index 0000000000..829e6a813a --- /dev/null +++ b/sandbox/egg_agent_tools/tools/jira.py @@ -0,0 +1,308 @@ +"""Jira-namespace @tool wrappers (#2994). + +Exposes the gateway-backed ``jira`` verbs as ``mcp__jira__*`` MCP tools +so the routes are discoverable in the agent's tool manifest every turn. +Every wrapper forwards to a handler in ``egg_agent_tools.handlers.jira``; +the gateway enforces all policy (project allowlist, the read vs. four- +write-route split, JQL scope, private-mode gate), so this layer carries +no credentials and adds no capability. + +The namespace key is ``jira`` so the Claude-visible names are +``mcp__jira__`` — intentionally matching the *host* MCP namespace +(not present in the sandbox), so planner-authored task text referencing +``mcp__jira__*`` resolves to the restricted sandbox tools. These verbs +have no ``egg-*`` CLI counterpart the drift test can walk, so every +``ToolRegistration`` sets ``cli_command=None`` (see #2994 and the +handler module docstring). +""" + +from __future__ import annotations + +from typing import Any + +from egg_agent_tools.handlers import jira as handlers +from egg_agent_tools.tools._common import invoke_handler +from egg_agent_tools.tools._registry import ToolRegistration +from egg_agent_tools.tools._tool_compat import tool + +NAMESPACE = "jira" + +_FIELDS = { + "type": "array", + "items": {"type": "string"}, + "description": "Field names to return (omit for Atlassian's default set).", +} +_LABELS = {"type": "array", "items": {"type": "string"}, "description": "Label names."} +_IDEMPOTENCY = { + "type": "string", + "description": "Idempotency key — makes a retried write safe (no duplicate).", +} + +_TICKET_GET_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "ticket": {"type": "string", "description": "Ticket key, e.g. ENG-123."}, + "fields": _FIELDS, + }, + "required": ["ticket"], +} + +_TICKET_KEY_ONLY_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "ticket": {"type": "string", "description": "Ticket key, e.g. ENG-123."}, + }, + "required": ["ticket"], +} + +_SEARCH_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "jql": { + "type": "string", + "description": ( + "JQL query. Must statically scope to allowlisted projects " + "(e.g. 'project = ENG AND status = Open'); an OR over `project` is denied." + ), + }, + "max_results": {"type": "integer", "description": "Max issues to return."}, + "fields": _FIELDS, + "next_page_token": { + "type": "string", + "description": "Opaque pagination token from a prior response.", + }, + }, + "required": ["jql"], +} + +_TICKET_CREATE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project key (must be allowlisted)."}, + "issue_type": {"type": "string", "description": "Issue type, e.g. Task / Bug / Story."}, + "summary": {"type": "string", "description": "Ticket summary / title."}, + "description": {"type": "string", "description": "Ticket description (plain text)."}, + "labels": _LABELS, + "parent": {"type": "string", "description": "Parent issue key (for sub-tasks)."}, + "epic_link": {"type": "string", "description": "Epic key to link under."}, + "idempotency_key": _IDEMPOTENCY, + }, + "required": ["project", "issue_type", "summary"], +} + +_TICKET_EDIT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "ticket": {"type": "string", "description": "Ticket key to edit, e.g. ENG-123."}, + "summary": {"type": "string", "description": "New summary."}, + "description": {"type": "string", "description": "New description (plain text)."}, + "labels": { + **_LABELS, + "description": "Replace all labels (mutually exclusive with add/remove_labels).", + }, + "add_labels": {**_LABELS, "description": "Labels to add (incremental)."}, + "remove_labels": {**_LABELS, "description": "Labels to remove (incremental)."}, + "notify_users": { + "type": "boolean", + "description": ( + "Send Jira notifications (default true, matching the " + "sandbox/scripts/jira wrapper; pass false to suppress)." + ), + }, + }, + "required": ["ticket"], +} + +_TICKET_COMMENT_ADD_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "ticket": {"type": "string", "description": "Ticket key, e.g. ENG-123."}, + "body": {"type": "string", "description": "Comment body (plain text)."}, + "idempotency_key": _IDEMPOTENCY, + }, + "required": ["ticket", "body"], +} + +_LINK_CREATE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "link_type": {"type": "string", "description": "Jira link name, e.g. Blocks / Relates."}, + "inward_issue": {"type": "string", "description": "Inward issue key, e.g. FOO-1."}, + "outward_issue": {"type": "string", "description": "Outward issue key, e.g. FOO-2."}, + "comment": {"type": "string", "description": "Optional comment to attach to the link."}, + "idempotency_key": _IDEMPOTENCY, + }, + "required": ["link_type", "inward_issue", "outward_issue"], +} + +_EXECUTE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "method": {"type": "string", "description": "HTTP method — GET only (others 403)."}, + "path": {"type": "string", "description": "Jira REST path (allowlisted)."}, + "query": {"type": "object", "description": "Query-string key/value pairs."}, + }, + "required": ["method", "path"], +} + + +@tool( + "ticket_get", + "Fetch a Jira ticket by key (e.g. ENG-123) via the gateway " + "(project-allowlisted, read). Optional `fields` narrows the result.", + _TICKET_GET_SCHEMA, +) +async def jira_ticket_get(args: dict[str, Any]) -> dict[str, Any]: + # A ticket with `expand=renderedBody,renderedFields` (the gateway default) + # can cross 1 MB on a long-running issue. Spill oversized payloads via + # the `spill=True` surface in ``tools/_common.py`` so the agent can + # Read/grep the full payload from disk. + return await invoke_handler(handlers.jira_ticket_get, args, spill=True) + + +@tool( + "ticket_comments", + "Fetch the comments on a Jira ticket via the gateway.", + _TICKET_KEY_ONLY_SCHEMA, +) +async def jira_ticket_comments(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_ticket_comments, args) + + +@tool( + "ticket_remotelinks", + "Fetch the remote links on a Jira ticket via the gateway (surfaces " + "PRs humans opened against the ticket).", + _TICKET_KEY_ONLY_SCHEMA, +) +async def jira_ticket_remotelinks(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_ticket_remotelinks, args) + + +@tool( + "search", + "Search Jira issues with JQL via the gateway. JQL must statically " + "scope to allowlisted projects (an OR over `project` is denied). " + "Paginate with `next_page_token`.", + _SEARCH_SCHEMA, +) +async def jira_search(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_search, args) + + +@tool( + "ticket_create", + "Create a new Jira ticket via the gateway (project-allowlisted, " + "private-mode). State-machine effect: creates a new issue. Pass " + "`idempotency_key` to make a retry safe.", + _TICKET_CREATE_SCHEMA, +) +async def jira_ticket_create(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_ticket_create, args) + + +@tool( + "ticket_edit", + "Edit a Jira ticket via the gateway. `labels` (replace) is mutually " + "exclusive with `add_labels`/`remove_labels` (incremental). " + "State-machine effect: mutates the issue fields in place.", + _TICKET_EDIT_SCHEMA, +) +async def jira_ticket_edit(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_ticket_edit, args) + + +@tool( + "ticket_comment_add", + "Add a comment to a Jira ticket via the gateway. Pass `idempotency_key` to make a retry safe.", + _TICKET_COMMENT_ADD_SCHEMA, +) +async def jira_ticket_comment_add(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_ticket_comment_add, args) + + +@tool( + "link_create", + "Link two Jira tickets via the gateway (both projects must be " + "allowlisted). State-machine effect: creates an issue link.", + _LINK_CREATE_SCHEMA, +) +async def jira_link_create(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_link_create, args) + + +@tool( + "execute", + "Raw read-only Jira REST passthrough via the gateway (GET-only escape " + "hatch for routes without a dedicated verb; non-GET returns 403).", + _EXECUTE_SCHEMA, +) +async def jira_execute(args: dict[str, Any]) -> dict[str, Any]: + return await invoke_handler(handlers.jira_execute, args) + + +REGISTRATIONS: list[ToolRegistration] = [ + ToolRegistration( + name="mcp__jira__ticket_get", + namespace=NAMESPACE, + handler=handlers.jira_ticket_get, + sdk_tool=jira_ticket_get, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__ticket_comments", + namespace=NAMESPACE, + handler=handlers.jira_ticket_comments, + sdk_tool=jira_ticket_comments, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__ticket_remotelinks", + namespace=NAMESPACE, + handler=handlers.jira_ticket_remotelinks, + sdk_tool=jira_ticket_remotelinks, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__search", + namespace=NAMESPACE, + handler=handlers.jira_search, + sdk_tool=jira_search, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__ticket_create", + namespace=NAMESPACE, + handler=handlers.jira_ticket_create, + sdk_tool=jira_ticket_create, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__ticket_edit", + namespace=NAMESPACE, + handler=handlers.jira_ticket_edit, + sdk_tool=jira_ticket_edit, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__ticket_comment_add", + namespace=NAMESPACE, + handler=handlers.jira_ticket_comment_add, + sdk_tool=jira_ticket_comment_add, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__link_create", + namespace=NAMESPACE, + handler=handlers.jira_link_create, + sdk_tool=jira_link_create, + cli_command=None, + ), + ToolRegistration( + name="mcp__jira__execute", + namespace=NAMESPACE, + handler=handlers.jira_execute, + sdk_tool=jira_execute, + cli_command=None, + ), +] diff --git a/tests/sandbox/egg_agent_tools/test_full_tool_registry.py b/tests/sandbox/egg_agent_tools/test_full_tool_registry.py index dfe4d4793b..eb0c82dcea 100644 --- a/tests/sandbox/egg_agent_tools/test_full_tool_registry.py +++ b/tests/sandbox/egg_agent_tools/test_full_tool_registry.py @@ -127,7 +127,7 @@ class TestToolCountAndNamespaces: """Derived assertions locked here so the integration suite trips on silent drift. - Count is **28** — ``mcp__brc__wait_for_event`` and + Count is **45** — ``mcp__brc__wait_for_event`` and ``mcp__brc__wait_loop`` were removed in #2211 (long-poll waits don't fit the in-process SDK MCP transport's ~60 s tool-call cap; agents now use ``egg-orch message wait`` / ``wait-loop`` via Bash). @@ -136,12 +136,15 @@ class TestToolCountAndNamespaces: ``mcp__sdlc__check_file_restriction`` and ``mcp__sdlc__report_impasse`` were added in #2529 for the runtime escape-hatch — the agent self-checks role/file boundaries and - emits a typed Impasse instead of inventing workarounds. - The ``mcp__checkpoint__*`` trio was removed in #2993 (checkpoint - subsystem removal). + emits a typed Impasse instead of inventing workarounds. The + ``mcp__checkpoint__*`` trio was removed in #2993 (checkpoint + subsystem removal). The 17 Atlassian-gateway verbs (8 + ``mcp__confluence__*`` + 9 ``mcp__jira__*``) were added in #2994 to + make the gateway's Confluence/Jira routes discoverable as MCP tools + (28 + 17 = 45). """ - EXPECTED_TOOL_COUNT = 28 + EXPECTED_TOOL_COUNT = 45 def test_tool_count(self): assert len(TOOL_LIST) == self.EXPECTED_TOOL_COUNT diff --git a/tests/sandbox/egg_agent_tools/test_handlers_confluence.py b/tests/sandbox/egg_agent_tools/test_handlers_confluence.py new file mode 100644 index 0000000000..5eb16a1e7f --- /dev/null +++ b/tests/sandbox/egg_agent_tools/test_handlers_confluence.py @@ -0,0 +1,188 @@ +"""Unit tests for egg_agent_tools.handlers.confluence (#2994). + +The handlers are thin: build the gateway body (snake_case args → +camelCase fields) and POST it via ``gateway_data_request``. We patch +that helper and assert the endpoint + body, plus required-field +validation and list/CSV normalisation. A separate class exercises the +real ``gateway_data_request`` unwrap (success envelope → ``data``). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "sandbox")) +sys.path.insert(0, str(ROOT / "shared")) + +from egg_agent_tools.handlers import confluence # noqa: E402 +from egg_agent_tools.handlers.errors import GatewayError, HandlerError # noqa: E402 + + +def _patch_gateway(return_value=None): + return patch( + "egg_agent_tools.handlers.confluence.gateway_data_request", + return_value=return_value if return_value is not None else {"ok": True}, + ) + + +class TestPageGet: + def test_minimal_body(self): + with _patch_gateway() as gw: + confluence.confluence_page_get({"page_id": "12345"}) + endpoint = gw.call_args.args[0] + body = gw.call_args.kwargs["body"] + assert endpoint == "/api/v1/confluence/page/get" + assert body == {"pageId": "12345"} + + def test_body_format_and_expand_lists(self): + with _patch_gateway() as gw: + confluence.confluence_page_get( + {"page_id": "1", "body_format": ["storage", "view"], "expand": ["version"]} + ) + body = gw.call_args.kwargs["body"] + assert body["bodyFormat"] == ["storage", "view"] + assert body["expand"] == ["version"] + + def test_body_format_accepts_csv_string(self): + """A model occasionally passes the gateway's CSV form; tolerate it.""" + with _patch_gateway() as gw: + confluence.confluence_page_get({"page_id": "1", "body_format": "storage, view"}) + assert gw.call_args.kwargs["body"]["bodyFormat"] == ["storage", "view"] + + def test_missing_page_id_raises(self): + with pytest.raises(HandlerError): + confluence.confluence_page_get({}) + + def test_blank_page_id_raises(self): + with pytest.raises(HandlerError): + confluence.confluence_page_get({"page_id": " "}) + + +class TestPageDescendants: + def test_translates_pagination(self): + with _patch_gateway() as gw: + confluence.confluence_page_descendants( + {"page_id": "9", "depth": 2, "limit": 50, "cursor": "tok"} + ) + endpoint = gw.call_args.args[0] + body = gw.call_args.kwargs["body"] + assert endpoint == "/api/v1/confluence/page/descendants" + assert body == {"pageId": "9", "depth": 2, "limit": 50, "cursor": "tok"} + + +class TestFooterComments: + def test_include_replies_default_false(self): + with _patch_gateway() as gw: + confluence.confluence_page_footer_comments({"page_id": "1"}) + assert gw.call_args.kwargs["body"]["includeReplies"] is False + + def test_include_replies_true(self): + with _patch_gateway() as gw: + confluence.confluence_page_footer_comments({"page_id": "1", "include_replies": True}) + assert gw.call_args.kwargs["body"]["includeReplies"] is True + + +class TestInlineComments: + def test_endpoint(self): + with _patch_gateway() as gw: + confluence.confluence_page_inline_comments({"page_id": "1"}) + assert gw.call_args.args[0] == "/api/v1/confluence/page/inline-comments" + + +class TestSpacePages: + def test_space_key_required(self): + with pytest.raises(HandlerError): + confluence.confluence_space_pages({}) + + def test_body(self): + with _patch_gateway() as gw: + confluence.confluence_space_pages({"space_key": "ENG", "limit": 10}) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/confluence/space/pages" + assert body == {"spaceKey": "ENG", "limit": 10} + + +class TestSpaceList: + def test_empty_body(self): + with _patch_gateway() as gw: + confluence.confluence_space_list({}) + assert gw.call_args.args[0] == "/api/v1/confluence/space/list" + assert gw.call_args.kwargs["body"] == {} + + def test_pagination(self): + with _patch_gateway() as gw: + confluence.confluence_space_list({"limit": 5, "cursor": "c"}) + assert gw.call_args.kwargs["body"] == {"limit": 5, "cursor": "c"} + + +class TestSearch: + def test_cql_required(self): + with pytest.raises(HandlerError): + confluence.confluence_search({}) + + def test_body(self): + with _patch_gateway() as gw: + confluence.confluence_search({"cql": "space = ENG", "limit": 25}) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/confluence/search" + assert body == {"cql": "space = ENG", "limit": 25} + + +class TestExecute: + def test_method_and_path_required(self): + with pytest.raises(HandlerError): + confluence.confluence_execute({"method": "GET"}) + + def test_query_must_be_object(self): + with pytest.raises(HandlerError): + confluence.confluence_execute({"method": "GET", "path": "/x", "query": "k=v"}) + + def test_body(self): + with _patch_gateway() as gw: + confluence.confluence_execute( + {"method": "GET", "path": "/wiki/rest/api/space", "query": {"limit": "10"}} + ) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/confluence/execute" + assert body == { + "method": "GET", + "path": "/wiki/rest/api/space", + "query": {"limit": "10"}, + } + + +class TestGatewayDataRequestUnwrap: + """The shared unwrap helper returns ``data`` on success and raises on + a success=false body; HTTP-level failures are already raised by + ``gateway_request`` upstream.""" + + def test_returns_data_on_success(self): + with patch( + "egg_agent_tools.handlers._gateway.gateway_request", + return_value={"success": True, "message": "ok", "data": {"results": [1, 2]}}, + ): + out = confluence.confluence_space_list({}) + assert out == {"results": [1, 2]} + + def test_not_found_envelope_flows_through(self): + # The gateway returns success=true with data.status == not_found. + with patch( + "egg_agent_tools.handlers._gateway.gateway_request", + return_value={"success": True, "data": {"status": "not_found"}}, + ): + out = confluence.confluence_page_get({"page_id": "1"}) + assert out == {"status": "not_found"} + + def test_success_false_body_raises(self): + with patch( + "egg_agent_tools.handlers._gateway.gateway_request", + return_value={"success": False, "message": "denied", "details": {"space": "X"}}, + ): + with pytest.raises(GatewayError) as exc: + confluence.confluence_space_list({}) + assert "denied" in str(exc.value) diff --git a/tests/sandbox/egg_agent_tools/test_handlers_jira.py b/tests/sandbox/egg_agent_tools/test_handlers_jira.py new file mode 100644 index 0000000000..a1c7a168ee --- /dev/null +++ b/tests/sandbox/egg_agent_tools/test_handlers_jira.py @@ -0,0 +1,201 @@ +"""Unit tests for egg_agent_tools.handlers.jira (#2994). + +The handlers build the gateway body (snake_case args → camelCase fields) +and POST it via ``gateway_data_request`` (patched here). We assert the +endpoint + body, required-field validation, list normalisation, the +edit-labels mutual-exclusion rule, and the notify default. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "sandbox")) +sys.path.insert(0, str(ROOT / "shared")) + +from egg_agent_tools.handlers import jira # noqa: E402 +from egg_agent_tools.handlers.errors import HandlerError # noqa: E402 + + +def _patch_gateway(return_value=None): + return patch( + "egg_agent_tools.handlers.jira.gateway_data_request", + return_value=return_value if return_value is not None else {"ok": True}, + ) + + +class TestTicketGet: + def test_minimal(self): + with _patch_gateway() as gw: + jira.jira_ticket_get({"ticket": "ENG-1"}) + assert gw.call_args.args[0] == "/api/v1/jira/ticket/get" + assert gw.call_args.kwargs["body"] == {"ticket": "ENG-1"} + + def test_fields_list(self): + with _patch_gateway() as gw: + jira.jira_ticket_get({"ticket": "ENG-1", "fields": ["summary", "status"]}) + assert gw.call_args.kwargs["body"]["fields"] == ["summary", "status"] + + def test_fields_csv_string(self): + with _patch_gateway() as gw: + jira.jira_ticket_get({"ticket": "ENG-1", "fields": "summary, status"}) + assert gw.call_args.kwargs["body"]["fields"] == ["summary", "status"] + + def test_ticket_required(self): + with pytest.raises(HandlerError): + jira.jira_ticket_get({}) + + +class TestTicketCommentsAndRemoteLinks: + def test_comments_endpoint(self): + with _patch_gateway() as gw: + jira.jira_ticket_comments({"ticket": "ENG-2"}) + assert gw.call_args.args[0] == "/api/v1/jira/ticket/comments" + assert gw.call_args.kwargs["body"] == {"ticket": "ENG-2"} + + def test_remotelinks_endpoint(self): + with _patch_gateway() as gw: + jira.jira_ticket_remotelinks({"ticket": "ENG-2"}) + assert gw.call_args.args[0] == "/api/v1/jira/ticket/remotelinks" + + +class TestSearch: + def test_jql_required(self): + with pytest.raises(HandlerError): + jira.jira_search({}) + + def test_translates_camel(self): + with _patch_gateway() as gw: + jira.jira_search( + { + "jql": "project = ENG", + "max_results": 50, + "fields": ["summary"], + "next_page_token": "tok", + } + ) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/jira/search" + assert body == { + "jql": "project = ENG", + "maxResults": 50, + "fields": ["summary"], + "nextPageToken": "tok", + } + + +class TestTicketCreate: + def test_required_fields(self): + with pytest.raises(HandlerError): + jira.jira_ticket_create({"project": "ENG", "summary": "x"}) # missing issue_type + + def test_full_body(self): + with _patch_gateway() as gw: + jira.jira_ticket_create( + { + "project": "ENG", + "issue_type": "Task", + "summary": "Do thing", + "description": "details", + "labels": ["a", "b"], + "parent": "ENG-1", + "epic_link": "ENG-9", + "idempotency_key": "k1", + } + ) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/jira/ticket/create" + assert body == { + "project": "ENG", + "issuetype": "Task", + "summary": "Do thing", + "description": "details", + "labels": ["a", "b"], + "parent": "ENG-1", + "epicLink": "ENG-9", + "idempotencyKey": "k1", + } + + +class TestTicketEdit: + def test_notify_default_true(self): + # MCP defaults notify_users=True to match the sandbox/scripts/jira + # wrapper's deliberate override of the gateway default — keeps + # planner-authored task text producing the same side effects + # regardless of which front-end the agent picks. + with _patch_gateway() as gw: + jira.jira_ticket_edit({"ticket": "ENG-1", "summary": "new"}) + body = gw.call_args.kwargs["body"] + assert body["notifyUsers"] is True + assert body["summary"] == "new" + + def test_notify_false(self): + with _patch_gateway() as gw: + jira.jira_ticket_edit({"ticket": "ENG-1", "notify_users": False}) + assert gw.call_args.kwargs["body"]["notifyUsers"] is False + + def test_incremental_labels(self): + with _patch_gateway() as gw: + jira.jira_ticket_edit({"ticket": "ENG-1", "add_labels": ["x"], "remove_labels": ["y"]}) + body = gw.call_args.kwargs["body"] + assert body["addLabels"] == ["x"] + assert body["removeLabels"] == ["y"] + + def test_replace_and_incremental_mutually_exclusive(self): + with pytest.raises(HandlerError): + jira.jira_ticket_edit({"ticket": "ENG-1", "labels": ["a"], "add_labels": ["b"]}) + + +class TestCommentAdd: + def test_body_required(self): + with pytest.raises(HandlerError): + jira.jira_ticket_comment_add({"ticket": "ENG-1"}) + + def test_body(self): + with _patch_gateway() as gw: + jira.jira_ticket_comment_add({"ticket": "ENG-1", "body": "hi", "idempotency_key": "k"}) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/jira/ticket/comment/add" + assert body == {"ticket": "ENG-1", "body": "hi", "idempotencyKey": "k"} + + +class TestLinkCreate: + def test_required(self): + with pytest.raises(HandlerError): + jira.jira_link_create({"link_type": "Blocks", "inward_issue": "A-1"}) + + def test_body(self): + with _patch_gateway() as gw: + jira.jira_link_create( + { + "link_type": "Blocks", + "inward_issue": "A-1", + "outward_issue": "A-2", + "comment": "see", + } + ) + body = gw.call_args.kwargs["body"] + assert gw.call_args.args[0] == "/api/v1/jira/issue-link/create" + assert body == { + "type": "Blocks", + "inwardIssue": "A-1", + "outwardIssue": "A-2", + "comment": "see", + } + + +class TestExecute: + def test_query_must_be_object(self): + with pytest.raises(HandlerError): + jira.jira_execute({"method": "GET", "path": "/x", "query": "bad"}) + + def test_body(self): + with _patch_gateway() as gw: + jira.jira_execute({"method": "GET", "path": "/rest/api/3/myself"}) + assert gw.call_args.args[0] == "/api/v1/jira/execute" + assert gw.call_args.kwargs["body"] == {"method": "GET", "path": "/rest/api/3/myself"} diff --git a/tests/sandbox/egg_agent_tools/test_server.py b/tests/sandbox/egg_agent_tools/test_server.py index 9e06cd1e62..e42cb6534c 100644 --- a/tests/sandbox/egg_agent_tools/test_server.py +++ b/tests/sandbox/egg_agent_tools/test_server.py @@ -1,11 +1,14 @@ """Tests for egg_agent_tools.server (factory + system prompt nudge). Covers: -- build_sandbox_mcp_server registers the expected iteration-2 tools (29: - the 18 iteration-1 verbs plus the 12 iteration-2 additions landed in - #1917, minus 2 wait verbs removed in #2211, plus - ``mcp__brc__resolve_obligation`` added in #2338, across the sdlc, - brc, phase, progress, and task namespaces). +- build_sandbox_mcp_server registers the expected tools (45: the 18 + iteration-1 verbs plus the 12 iteration-2 additions landed in #1917, + minus 2 wait verbs removed in #2211, plus + ``mcp__brc__resolve_obligation`` added in #2338, plus + ``check_file_restriction`` / ``report_impasse`` in #2529, minus the + 3 checkpoint verbs removed in #2993, plus the 17 Atlassian-gateway + verbs in #2994, across the sdlc, brc, phase, progress, task, + confluence, and jira namespaces). - SYSTEM_PROMPT_NUDGE stays <=200 words. - Symmetric drift test: every mcp____ substring in the nudge corresponds to a registered namespace, and every registered namespace @@ -28,8 +31,9 @@ from egg_agent_tools.server import _render_nudge # noqa: E402 from egg_agent_tools.tools import TOOL_REGISTRY # noqa: E402 -# Iteration-1 verbs (18). Kept in its own set for documentation so the -# reader can see the #1917 additions clearly. +# Iteration-1 verbs (16 — original 18 minus the 2 wait verbs removed +# in #2211). Kept in its own set for documentation so the reader can +# see the #1917 additions clearly. _ITER1_TOOL_NAMES = { "mcp__sdlc__register_open_question", "mcp__sdlc__request_feedback", @@ -79,7 +83,36 @@ "mcp__sdlc__report_impasse", } -EXPECTED_TOOL_NAMES = _ITER1_TOOL_NAMES | _ITER2_TOOL_NAMES | _POST_ITER2_TOOL_NAMES +# #2994 — Atlassian gateway routes exposed as MCP servers for +# discoverability. Two net-new namespaces (``confluence``/``jira``) +# mirroring the ``sandbox/scripts/{confluence,jira}`` bash wrappers +# one-for-one; all gateway-backed, all ``cli_command=None`` (their CLI +# analog is a bash wrapper, not an ``egg-*`` argparse tree). +_ATLASSIAN_TOOL_NAMES = { + # confluence (8) + "mcp__confluence__page_get", + "mcp__confluence__page_descendants", + "mcp__confluence__page_footer_comments", + "mcp__confluence__page_inline_comments", + "mcp__confluence__space_pages", + "mcp__confluence__space_list", + "mcp__confluence__search", + "mcp__confluence__execute", + # jira (9) + "mcp__jira__ticket_get", + "mcp__jira__ticket_comments", + "mcp__jira__ticket_remotelinks", + "mcp__jira__search", + "mcp__jira__ticket_create", + "mcp__jira__ticket_edit", + "mcp__jira__ticket_comment_add", + "mcp__jira__link_create", + "mcp__jira__execute", +} + +EXPECTED_TOOL_NAMES = ( + _ITER1_TOOL_NAMES | _ITER2_TOOL_NAMES | _POST_ITER2_TOOL_NAMES | _ATLASSIAN_TOOL_NAMES +) EXPECTED_NAMESPACES = { "sdlc", @@ -87,6 +120,8 @@ "phase", "progress", "task", + "confluence", + "jira", } @@ -99,11 +134,13 @@ def test_tool_count_registered(self): # then +1 in #2338 (``mcp__brc__resolve_obligation``) = 29, # then +2 in #2529 (``check_file_restriction`` + # ``report_impasse`` — runtime escape hatch) = 31, - # then -3 in #2993 (checkpoint subsystem removed) = 28. + # then -3 in #2993 (checkpoint subsystem removed) = 28, + # then +17 in #2994 (8 confluence + 9 jira Atlassian-gateway + # verbs) = 45. # Derived assertion: trips when a future iteration drifts the # count without updating the prose verb-counts in # docs/reference/agent-tools.md. - assert len(TOOL_LIST) == 28 + assert len(TOOL_LIST) == 45 def test_expected_names_present(self): names = set(TOOL_REGISTRY.keys()) @@ -115,9 +152,11 @@ def test_tool_list_matches_namespace_mapping(self): flat.extend(tools) assert set(flat) == EXPECTED_TOOL_NAMES - def test_namespace_set_is_five(self): - # Derived assertion: exactly five namespaces - # (sdlc/brc/phase/progress/task). + def test_namespace_set(self): + # Derived assertion: exactly seven namespaces. The iter-1 five + # (sdlc/brc/phase/progress/task) plus the two Atlassian-gateway + # namespaces `confluence`/`jira` (#2994). The `checkpoint` + # namespace was removed in #2993. assert set(TOOL_NAMESPACES.keys()) == EXPECTED_NAMESPACES def test_iter2_tools_land_in_correct_namespace(self): diff --git a/tests/sandbox/egg_agent_tools/test_tools.py b/tests/sandbox/egg_agent_tools/test_tools.py index 2c690b3b3a..9c466c1d07 100644 --- a/tests/sandbox/egg_agent_tools/test_tools.py +++ b/tests/sandbox/egg_agent_tools/test_tools.py @@ -151,7 +151,7 @@ def test_spill_writes_file_and_returns_preview(self, tmp_path, monkeypatch): def handler(req): return {"ok": True, "transcript": "L" * (300 * 1024)} - resp = _run(invoke_handler(handler, {}, tool_name="checkpoint_show", spill=True)) + resp = _run(invoke_handler(handler, {}, tool_name="confluence_page_get", spill=True)) desc = json.loads(resp["content"][0]["text"]) assert desc["_egg_output_spilled"] is True assert Path(desc["output_path"]).exists() @@ -162,7 +162,7 @@ def test_spill_skipped_for_small_result(self): def handler(req): return {"ok": True, "transcript": "short"} - resp = _run(invoke_handler(handler, {}, tool_name="checkpoint_show", spill=True)) + resp = _run(invoke_handler(handler, {}, tool_name="confluence_page_get", spill=True)) body = json.loads(resp["content"][0]["text"]) assert body == {"ok": True, "transcript": "short"} @@ -362,6 +362,44 @@ def _handler(req): assert "branch ownership check failed" in error_text +class TestAtlassianWrappers: + """The #2994 ``mcp__confluence__*`` / ``mcp__jira__*`` wrappers route + through ``invoke_handler`` like every other tool: JSON-serialised + handler response on success, SDK-shaped is_error block on failure. + """ + + def test_confluence_space_list_wrapper_success(self): + with patch( + "egg_agent_tools.handlers.confluence.confluence_space_list", + return_value={"spaces": [{"key": "ENG"}]}, + ): + wrapper = TOOL_REGISTRY["mcp__confluence__space_list"].sdk_tool + resp = _run(wrapper.handler({})) + body = json.loads(resp["content"][0]["text"]) + assert body == {"spaces": [{"key": "ENG"}]} + assert "is_error" not in resp + + def test_jira_ticket_get_wrapper_wraps_gateway_error(self): + with patch( + "egg_agent_tools.handlers.jira.jira_ticket_get", + side_effect=GatewayError("forbidden", status_code=403), + ): + wrapper = TOOL_REGISTRY["mcp__jira__ticket_get"].sdk_tool + resp = _run(wrapper.handler({"ticket": "ENG-1"})) + assert resp["is_error"] is True + assert "forbidden" in resp["content"][0]["text"] + + def test_all_atlassian_tools_are_cli_less(self): + atlassian = [ + name for name, reg in TOOL_REGISTRY.items() if reg.namespace in ("confluence", "jira") + ] + assert len(atlassian) == 17 + for name in atlassian: + assert TOOL_REGISTRY[name].cli_command is None, ( + f"{name} mirrors a bash wrapper, not an egg-* CLI; cli_command must be None" + ) + + class TestMessagePrimitiveWrappers: """The remaining message-namespace MCP wrapper (``send_heartbeat``) returns JSON-serialised responses on success and SDK-shaped is_error diff --git a/tests/shared/test_egg_tool_output.py b/tests/shared/test_egg_tool_output.py index 1961666d1a..923eb436e2 100644 --- a/tests/shared/test_egg_tool_output.py +++ b/tests/shared/test_egg_tool_output.py @@ -115,7 +115,9 @@ def test_small_payload_returns_none(self, tmp_path): def test_oversized_payload_spilled(self, tmp_path): big = "\n".join(f"line-{i}" for i in range(5000)) - desc = spill_to_file(big, tool="checkpoint_show", cap_bytes=1000, spill_dir=str(tmp_path)) + desc = spill_to_file( + big, tool="confluence_page_get", cap_bytes=1000, spill_dir=str(tmp_path) + ) assert desc is not None assert desc[SPILL_KEY] is True assert desc["total_bytes"] == _utf8(big) @@ -137,7 +139,9 @@ def test_single_huge_line_preview_bounded(self, tmp_path): # A compact (one physical line) payload must still yield a small, # well-formed descriptor — the preview can't blow past its budget. big = "z" * (300 * 1024) - desc = spill_to_file(big, tool="checkpoint_show", cap_bytes=1000, spill_dir=str(tmp_path)) + desc = spill_to_file( + big, tool="confluence_page_get", cap_bytes=1000, spill_dir=str(tmp_path) + ) assert desc is not None assert _utf8(desc["preview"]) <= 4 * 1024 # output_path survives — it's the field that makes spill useful. @@ -150,7 +154,9 @@ def test_preview_budget_scales_with_small_cap(self, tmp_path): # descriptor dwarf the cap and risk the outer cap_text dropping # output_path). The full content is still preserved on disk. big = "z" * (300 * 1024) - desc = spill_to_file(big, tool="checkpoint_show", cap_bytes=2000, spill_dir=str(tmp_path)) + desc = spill_to_file( + big, tool="confluence_page_get", cap_bytes=2000, spill_dir=str(tmp_path) + ) assert desc is not None assert _utf8(desc["preview"]) <= 2000 assert Path(desc["output_path"]).exists() @@ -163,7 +169,9 @@ def test_stale_spills_pruned(self, tmp_path, monkeypatch): os.utime(stale, (old_time, old_time)) big = "\n".join(f"line-{i}" for i in range(5000)) - desc = spill_to_file(big, tool="checkpoint_show", cap_bytes=1000, spill_dir=str(tmp_path)) + desc = spill_to_file( + big, tool="confluence_page_get", cap_bytes=1000, spill_dir=str(tmp_path) + ) assert desc is not None assert not stale.exists() # The fresh spill is kept. diff --git a/tests/tools/test_mcp_cli_drift.py b/tests/tools/test_mcp_cli_drift.py index 9a8a31f821..57ab53ee92 100644 --- a/tests/tools/test_mcp_cli_drift.py +++ b/tests/tools/test_mcp_cli_drift.py @@ -214,6 +214,29 @@ def test_cli_less_tools_are_documented_gaps(): # what these tools already do directly. "mcp__sdlc__check_file_restriction", "mcp__sdlc__report_impasse", + # #2994 Atlassian-gateway verbs: these DO have a CLI analog, but + # it is the *bash* ``sandbox/scripts/{confluence,jira}`` wrapper, + # not a Python ``egg-*`` argparse tree this drift test can walk. + # So they set ``cli_command=None`` and carry a "no CLI" docstring + # rationale (asserted by tests/tools/test_rule_doc_drift.py + # assertion C) rather than a walkable subparser counterpart. + "mcp__confluence__page_get", + "mcp__confluence__page_descendants", + "mcp__confluence__page_footer_comments", + "mcp__confluence__page_inline_comments", + "mcp__confluence__space_pages", + "mcp__confluence__space_list", + "mcp__confluence__search", + "mcp__confluence__execute", + "mcp__jira__ticket_get", + "mcp__jira__ticket_comments", + "mcp__jira__ticket_remotelinks", + "mcp__jira__search", + "mcp__jira__ticket_create", + "mcp__jira__ticket_edit", + "mcp__jira__ticket_comment_add", + "mcp__jira__link_create", + "mcp__jira__execute", } actual_gaps = {name for name, reg in TOOL_REGISTRY.items() if reg.cli_command is None} assert actual_gaps == expected_gaps, (