Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 13 additions & 37 deletions agent/src/channel_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@

Currently wired channels:
- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools)
- ``jira`` → Atlassian Remote MCP (``mcp__jira-server__*`` tools)

``jira`` deliberately has NO entry: Atlassian's Remote MCP
(``mcp.atlassian.com``) requires an interactive, browser-based OAuth 2.1
flow with dynamic client registration and does not accept the stored Jira
REST OAuth token as a ``Bearer`` header, so it cannot connect from a
headless agent. Jira progress comments are posted out-of-band by
``jira_reactions`` (a REST shim wired into the pipeline) instead.

For all other channel sources this is a no-op: no MCP is written, and the
SDK sees no channel-specific tools.

See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound),
runner.py (SDK invocation).
runner.py (SDK invocation), jira_reactions.py (Jira outbound REST shim).
"""

from __future__ import annotations
Expand Down Expand Up @@ -52,47 +58,17 @@ def _linear_server_entry() -> dict[str, Any]:
}


# ─── Jira (Atlassian Remote MCP) ─────────────────────────────────────────────

#: Atlassian Remote MCP endpoint — Streamable HTTP transport.
#:
#: NOTE: Atlassian's Remote MCP rolled out in mid-2025 and may still be in
#: preview / gated rollout when this code first deploys. Confirm the public
#: URL + auth contract before relying on this in production. If gated, fall
#: back to a REST shim in a future ``jira_reactions.py`` module (Plan B).
JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse"

#: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*``
#: in the Agent SDK. If this changes the agent prompt's channel addendum
#: must be updated in lockstep.
JIRA_MCP_SERVER_KEY = "jira-server"

#: Env var name the Jira MCP server entry reads via ``${JIRA_API_TOKEN}``
#: placeholder expansion. Populated from the per-tenant OAuth secret by
#: config.resolve_jira_oauth_token.
JIRA_API_TOKEN_ENV = "JIRA_API_TOKEN" # noqa: S105 — env var *name*, not a secret value


def _jira_server_entry() -> dict[str, Any]:
"""Build the `mcpServers` entry for Atlassian's Remote MCP."""
return {
"type": "http",
"url": JIRA_MCP_URL,
"headers": {
"Authorization": f"Bearer ${{{JIRA_API_TOKEN_ENV}}}",
},
}


# ─── Dispatch ────────────────────────────────────────────────────────────────

#: Per-channel ``mcpServers`` entry builder. The channel_source values mirror
#: ``ChannelSource`` in cdk/src/handlers/shared/types.ts. Sources that don't
#: have a hosted MCP (api, webhook, slack) intentionally have no entry here —
#: the gate in ``configure_channel_mcp`` short-circuits on missing keys.
#: have a usable hosted MCP intentionally have no entry here — the gate in
#: ``configure_channel_mcp`` short-circuits on missing keys. That includes
#: ``jira``: the Atlassian Remote MCP cannot authenticate from a headless
#: agent (see module docstring), so writing an entry would only produce a
#: confusing "Failed to connect" in every Jira task's logs.
CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
"linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry),
"jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry),
}


Expand Down
12 changes: 6 additions & 6 deletions agent/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,16 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) ->
The orchestrator stamps ``jira_oauth_secret_arn`` into the task
record's ``channel_metadata`` at task-creation time. We fetch the
per-tenant secret, parse the token JSON, refresh if expiring, and
cache the access_token in ``JIRA_API_TOKEN`` so the Atlassian Remote
MCP's ``${JIRA_API_TOKEN}`` placeholder in ``.mcp.json`` resolves.
cache the access_token in ``JIRA_API_TOKEN`` so the ``jira_reactions``
REST shim (which posts progress comments on the originating issue)
can authorize its calls.

For local development, a pre-set ``JIRA_API_TOKEN`` env var
short-circuits the lookup so the agent can run outside the runtime.

Returns an empty string when the credential is absent — the agent-side
MCP config then renders with an unresolved ``${JIRA_API_TOKEN}``
placeholder and the Jira MCP fails closed. This function is only
called when ``channel_source == 'jira'``.
Returns an empty string when the credential is absent —
``jira_reactions`` then skips its comments (fail closed, logged).
This function is only called when ``channel_source == 'jira'``.

Mirrors :func:`resolve_linear_api_token` in shape; differences are
only the secret key names, env var names, and OAuth endpoint
Expand Down
5 changes: 3 additions & 2 deletions agent/src/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,8 +724,9 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
# discover_project_config so the scan picks up the file we just
# wrote. Resolve the per-channel access token from Secrets
# Manager *before* writing .mcp.json so the child SDK process
# inherits the env var that the MCP server entry references
# (${LINEAR_API_TOKEN} / ${JIRA_API_TOKEN}).
# inherits the env var the MCP server entry references
# (${LINEAR_API_TOKEN}). Jira has no MCP entry — its token only
# feeds the jira_reactions REST shim below.
if config.channel_source == "linear":
resolve_linear_api_token(config.channel_metadata)
elif config.channel_source == "jira":
Expand Down
77 changes: 17 additions & 60 deletions agent/tests/test_channel_mcp.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
"""Unit tests for channel_mcp.configure_channel_mcp — Linear/Jira MCP gating + merge."""
"""Unit tests for channel_mcp.configure_channel_mcp — channel MCP gating + merge."""

from __future__ import annotations

import json
import os

from channel_mcp import (
JIRA_API_TOKEN_ENV,
JIRA_MCP_SERVER_KEY,
JIRA_MCP_URL,
LINEAR_API_TOKEN_ENV,
LINEAR_MCP_SERVER_KEY,
LINEAR_MCP_URL,
Expand Down Expand Up @@ -144,69 +141,29 @@ def test_empty_repo_dir_string(self):
assert wrote is False


class TestJiraWrite:
"""channel_source=='jira' writes .mcp.json with the jira-server entry."""
class TestJiraGate:
"""channel_source=='jira' is a deliberate no-op.

def test_creates_mcp_json_with_jira_server_key(self, tmp_path):
wrote = configure_channel_mcp(str(tmp_path), "jira")
assert wrote is True
config = _read_mcp(str(tmp_path))
assert JIRA_MCP_SERVER_KEY in config["mcpServers"]

def test_renders_jira_url_and_token_placeholder(self, tmp_path):
configure_channel_mcp(str(tmp_path), "jira")
entry = _read_mcp(str(tmp_path))["mcpServers"][JIRA_MCP_SERVER_KEY]
assert entry["type"] == "http"
assert entry["url"] == JIRA_MCP_URL
assert entry["headers"]["Authorization"] == f"Bearer ${{{JIRA_API_TOKEN_ENV}}}"

def test_server_key_is_jira_server(self):
# If this changes, tools surface under a different mcp__ prefix and
# the agent prompt addendum must be updated in lockstep.
assert JIRA_MCP_SERVER_KEY == "jira-server"
Atlassian's Remote MCP requires an interactive OAuth 2.1 flow a headless
agent can't complete, so no entry is written — Jira progress comments are
posted out-of-band by ``jira_reactions`` (REST shim). If a usable
server-to-server auth path ships, restore the entry in
``CHANNEL_MCP_BUILDERS`` and flip these assertions.
"""

def test_no_op_for_jira_channel(self, tmp_path):
wrote = configure_channel_mcp(str(tmp_path), "jira")
assert wrote is False
assert not (tmp_path / ".mcp.json").exists()

class TestJiraMerge:
"""Jira entry must coexist with other servers and overwrite stale jira entries."""

def test_preserves_existing_mcp_servers(self, tmp_path):
def test_jira_does_not_touch_existing_mcp_json(self, tmp_path):
existing = {
"mcpServers": {
"other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"},
},
}
(tmp_path / ".mcp.json").write_text(json.dumps(existing))

configure_channel_mcp(str(tmp_path), "jira")
merged = _read_mcp(str(tmp_path))
assert "other-server" in merged["mcpServers"]
assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp"
assert JIRA_MCP_SERVER_KEY in merged["mcpServers"]

def test_overwrites_existing_jira_server_entry(self, tmp_path):
existing = {
"mcpServers": {
JIRA_MCP_SERVER_KEY: {
"type": "http",
"url": "https://stale.example",
"headers": {"Authorization": "Bearer stale"},
},
},
}
(tmp_path / ".mcp.json").write_text(json.dumps(existing))

configure_channel_mcp(str(tmp_path), "jira")
entry = _read_mcp(str(tmp_path))["mcpServers"][JIRA_MCP_SERVER_KEY]
assert entry["url"] == JIRA_MCP_URL
assert "stale" not in entry["headers"]["Authorization"]

def test_linear_and_jira_can_coexist(self, tmp_path):
# Belt-and-braces: a repo that committed a Linear entry and then
# gets onboarded to Jira (or vice-versa) must keep both. The current
# code path only writes one channel per run, but this test guards
# against a future refactor that writes the wrong key.
configure_channel_mcp(str(tmp_path), "linear")
configure_channel_mcp(str(tmp_path), "jira")
merged = _read_mcp(str(tmp_path))
assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"]
assert JIRA_MCP_SERVER_KEY in merged["mcpServers"]
wrote = configure_channel_mcp(str(tmp_path), "jira")
assert wrote is False
assert _read_mcp(str(tmp_path)) == existing
4 changes: 2 additions & 2 deletions agent/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,8 @@ class TestResolveJiraOauthToken:
The orchestrator stamps `jira_oauth_secret_arn` into the task's
channel_metadata at creation time. resolve_jira_oauth_token reads the
secret JSON via boto3, refreshes it if expiring, and caches the
access_token in `JIRA_API_TOKEN` for the Atlassian Remote MCP
placeholder. Mirrors resolve_linear_api_token; the differences are the
access_token in `JIRA_API_TOKEN` for the jira_reactions REST shim.
Mirrors resolve_linear_api_token; the differences are the
secret/env var names and the Atlassian OAuth endpoint (JSON body).
"""

Expand Down
11 changes: 6 additions & 5 deletions cdk/src/constructs/jira-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export interface JiraIntegrationProps {
* CDK construct that adds Jira Cloud integration to the ABCA platform.
*
* Inbound-only adapter: Jira → webhook → task creation. Outbound progress
* updates happen agent-side via the Atlassian Remote MCP server (see
* agent/src/channel_mcp.py), so there is NO DynamoDB Streams consumer
* updates happen agent-side via the Jira REST API (see
* agent/src/jira_reactions.py), so there is NO DynamoDB Streams consumer
* and NO outbound-notify Lambda here. Mirrors the Linear adapter shape.
*
* Creates:
Expand Down Expand Up @@ -132,9 +132,10 @@ export class JiraIntegration extends Construct {

// --- Webhook signing secret (placeholder, populated by `bgagent jira setup`) ---
// Per-tenant OAuth tokens live in `bgagent-jira-oauth-<cloudId>` secrets
// created by the CLI at runtime — not here. This stack-wide secret is
// a back-compat fallback for single-tenant installs predating per-
// tenant signing.
// created by the CLI at runtime — not here. This stack-wide secret
// covers Settings-UI webhooks, whose payloads omit `cloudId` and so
// can't be verified per-tenant; the processor binds such deliveries
// to the sole active tenant (and drops them when that's ambiguous).
this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', {
description: 'Jira webhook signing secret — populate via `bgagent jira setup`',
removalPolicy,
Expand Down
56 changes: 48 additions & 8 deletions cdk/src/handlers/jira-webhook-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async function resolveSoleTenantCloudId(): Promise<string | undefined> {
* Undocumented fields are tolerated.
*/
interface JiraIssueEvent {
readonly webhookEvent: 'jira:issue_created' | 'jira:issue_updated' | string;
readonly webhookEvent: string;
readonly timestamp?: number;
readonly cloudId?: string;
readonly user?: {
Expand Down Expand Up @@ -155,6 +155,20 @@ interface JiraIssueEvent {

interface ProcessorEvent {
readonly raw_body: string;
/**
* How the receiver verified the delivery's signature.
*
* - `'per-tenant'` — the signature matched the secret stored on the
* tenant the body's `cloudId` names, so that `cloudId` is BOUND to
* the verified secret and may be trusted.
* - `'stack-wide'` (or absent, for back-compat with in-flight
* deliveries) — the signature only proves possession of the shared
* stack-wide secret. A body-supplied `cloudId` carries NO binding to
* that secret, so the processor must NOT use it to select a tenant;
* it resolves the sole active tenant from the registry instead and
* drops the event if that resolution is ambiguous.
*/
readonly verified_via?: 'per-tenant' | 'stack-wide';
}

/**
Expand All @@ -169,7 +183,8 @@ interface ProcessorEvent {
* - Resolve `(cloudId, projectKey)` → repo mapping.
* - Resolve `(cloudId, accountId)` → platform user mapping.
* - Call `createTaskCore` with `channelSource: 'jira'` and metadata the
* agent uses to address the originating issue via the Jira MCP.
* agent runtime uses to address the originating issue via the Jira REST
* API (`agent/src/jira_reactions.py`).
*/
export async function handler(event: ProcessorEvent): Promise<void> {
if (!event.raw_body) {
Expand Down Expand Up @@ -201,11 +216,36 @@ export async function handler(event: ProcessorEvent): Promise<void> {
return;
}

// `cloudId` is absent from Settings-UI webhook payloads. For a
// single-tenant install we recover it from the registry (see
// resolveSoleTenantCloudId); multi-tenant installs must send a webhook
// that carries its own cloudId.
const cloudId = payload.cloudId ?? (await resolveSoleTenantCloudId());
// Tenant resolution depends on how the receiver verified the signature:
//
// - per-tenant verification bound the body's `cloudId` to the secret that
// verified it, so we can trust it directly.
// - stack-wide verification proves only possession of the shared secret —
// a body-supplied `cloudId` is attacker-controllable and must NOT steer
// tenant selection (mappings, user attribution, OAuth bundle). We
// resolve the sole active tenant from the registry instead; if the
// payload names a different tenant, we drop rather than guess.
//
// `cloudId` is also absent entirely from Settings-UI webhook payloads —
// the same sole-tenant fallback covers that case.
let cloudId: string | undefined;
if (payload.cloudId && event.verified_via === 'per-tenant') {
cloudId = payload.cloudId;
} else {
cloudId = await resolveSoleTenantCloudId();
if (payload.cloudId && cloudId && payload.cloudId !== cloudId) {
logger.warn(
'Jira webhook cloudId not bound by per-tenant verification and differs from the sole active tenant — dropping',
{
payload_cloud_id: payload.cloudId,
sole_tenant_cloud_id: cloudId,
issue_key: issue.key,
verified_via: event.verified_via ?? 'unknown',
},
);
return;
}
}
const projectKey = issue.fields?.project?.key;
if (!projectKey) {
logger.info('Jira issue has no project.key — skipping (cannot route to a repo)', {
Expand Down Expand Up @@ -244,7 +284,7 @@ export async function handler(event: ProcessorEvent): Promise<void> {
await safeReportIssueFailure(
issue.key,
cloudId,
"❌ This Jira project isn't onboarded to ABCA. An admin can onboard it with `bgagent jira onboard-project <projectKey> --repo <owner>/<repo> --label <trigger>`.",
"❌ This Jira project isn't onboarded to ABCA. An admin can onboard it with `bgagent jira map <cloud-id> <project-key> --repo <owner>/<repo>`.",
);
return;
}
Expand Down
Loading