Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3a750fd
docs(guides): note supported Node version range in Quick Start prereq…
Jun 5, 2026
1560467
feat(types): add 'jira' to ChannelSource union
Jun 8, 2026
1a634dd
feat(jira): add DDB table constructs for projects, users, workspaces
Jun 8, 2026
a1223e3
feat(jira): add webhook, processor, link Lambdas + shared helpers
Jun 8, 2026
27c2cbd
feat(jira): add JiraIntegration construct + stack wiring
Jun 8, 2026
7bb0969
feat(jira): wire agent-side MCP + OAuth resolver for jira channel
Jun 8, 2026
37e4ea2
feat(cli): add bgagent jira commands (app-template, setup, link, map)
Jun 8, 2026
9562ab3
test(jira): add webhook, processor, and link handler tests
Jun 8, 2026
ddfa65b
docs(jira): add setup guide, ADR-014, and integration listings
Jun 8, 2026
8393f14
test(jira): close build/coverage gaps for jira integration
Jun 9, 2026
e5cd8ca
fix(jira): resolve cloudId from sole tenant when webhook omits it
Jun 10, 2026
b4e94e1
feat(jira): post issue progress comments via REST shim
Jun 10, 2026
c84cc66
Merge branch 'main' into feat/288-jira-integration
mayakost Jun 10, 2026
1e4a4a0
fix(jira): repair botched merge in test_config.py imports
Jun 10, 2026
0ca9003
fix(jira): type _config base dict so ty accepts TaskConfig(**base)
Jun 10, 2026
ae7e70f
fix(jira): apply ruff format and resync stale docs mirrors
Jun 10, 2026
2dcfb3c
fix(jira): address PR #302 review — security binding, token refresh, …
Jun 10, 2026
0f47343
Merge branch 'main' into feat/288-jira-integration
mayakost Jun 10, 2026
97234f2
fix(docs): repair botched main-merge in sync-starlight.mjs
Jun 10, 2026
9ca9b40
fix(cdk): repair botched main-merge in agent.ts
Jun 10, 2026
cda7db4
fix(cdk): correct DynamoDB table-count assertion + import ordering
Jun 10, 2026
15c559b
Merge remote-tracking branch 'upstream/main' into feat/288-jira-integ…
Jun 10, 2026
9dd66b8
fix(jira): post Lambda-side feedback to api.atlassian.com gateway base
Jun 11, 2026
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

## What is ABCA

**ABCA (Autonomous Background Coding Agents on AWS)** is a sample of what a self-hosted background coding agents platform might look like on AWS. You submit a coding task (via Slack, Linear, CLI, or webhook), walk away, and come back to a ready-to-review PR. The agent clones the repo, writes code, runs tests, and opens the PR autonomously in an isolated cloud environment. No babysitting, no IDE sessions, no back-and-forth.
**ABCA (Autonomous Background Coding Agents on AWS)** is a sample of what a self-hosted background coding agents platform might look like on AWS. You submit a coding task (via Slack, Linear, Jira, CLI, or webhook), walk away, and come back to a ready-to-review PR. The agent clones the repo, writes code, runs tests, and opens the PR autonomously in an isolated cloud environment. No babysitting, no IDE sessions, no back-and-forth.

## Why it matters

Expand All @@ -31,7 +31,7 @@

## The Use Case

Users submit tasks through webhooks, CLI, Slack,... For each task, the orchestrator executes the blueprint: an isolated environment is provisioned, an agent clones the target GitHub repository, creates a branch, works on the task, and opens a pull request.
Users submit tasks through webhooks, CLI, Slack, Linear, Jira,... For each task, the orchestrator executes the blueprint: an isolated environment is provisioned, an agent clones the target GitHub repository, creates a branch, works on the task, and opens a pull request.

Key characteristics:

Expand Down
117 changes: 96 additions & 21 deletions agent/src/channel_mcp.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
"""Channel-specific MCP configuration for the agent container.

For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned
repo ``cwd`` so the Claude Agent SDK — configured with
``setting_sources=["project"]`` — picks up the Linear MCP at session start
and exposes ``mcp__linear-server__*`` tools.
For inbound channel sources that have a hosted MCP we write (or merge into)
``.mcp.json`` in the cloned repo ``cwd`` so the Claude Agent SDK — configured
with ``setting_sources=["project"]`` — picks up the channel MCP at session
start and exposes the server's tools.

Currently wired channels:
- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional.
- ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It
is written for forward-compatibility but cannot connect from a headless
agent (interactive OAuth 2.1 only); live outbound Jira comments go through
the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015.

For all other channel sources this is a no-op: no MCP is written, and the
SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks
from touching Linear.
SDK sees no channel-specific tools.

See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py
(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md.
See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound),
runner.py (SDK invocation).
"""

from __future__ import annotations

import json
import os
from typing import Any
from typing import TYPE_CHECKING, Any

from shell import log

if TYPE_CHECKING:
from collections.abc import Callable

# ─── Linear ──────────────────────────────────────────────────────────────────

#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport.
LINEAR_MCP_URL = "https://mcp.linear.app/mcp"

#: Key name inside ``mcpServers``. Tools surface as
#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings).
#: ``mcp__linear-server__*`` in the Agent SDK.
LINEAR_MCP_SERVER_KEY = "linear-server"

#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}``
#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh.
#: placeholder expansion. Populated from the OAuth secret by config.py.
LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value


Expand All @@ -44,11 +55,62 @@ def _linear_server_entry() -> dict[str, Any]:
}


# ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ────────────────

#: Atlassian Remote MCP endpoint — Streamable HTTP transport.
#:
#: IMPORTANT: this entry does NOT work from a headless agent and is retained
#: only as a forward-looking placeholder. The hosted Atlassian MCP requires an
#: interactive, browser-based OAuth 2.1 flow with dynamic client registration
#: and will NOT accept the stored REST OAuth token as a Bearer header, so it
#: fails to connect in the runtime (``claude mcp list`` → "Failed to connect").
#:
#: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py``
#: (the "Plan B" that became Plan A), which posts comments via the Jira REST
#: v3 API using the same stored OAuth token. See ADR-015 and
#: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible
#: MCP, this entry can be promoted and the REST shim retired.
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.
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),
}


def _read_existing_mcp_config(path: str) -> dict[str, Any]:
"""Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid.

Malformed JSON is logged and treated as absent — we prefer to overlay a
valid Linear entry than to crash the agent because a user committed a
valid channel entry than to crash the agent because a user committed a
broken .mcp.json to their repo.
"""
if not os.path.isfile(path):
Expand All @@ -67,23 +129,26 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]:
def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
"""Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``.

Gated on ``channel_source``:
* ``'linear'`` → ensure the ``linear-server`` entry is present in
Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`:
* present → ensure the corresponding ``mcpServers`` entry is in
``.mcp.json`` (merges into any existing config without clobbering
other servers). Returns True.
* anything else → no-op. Returns False.
* absent → no-op. Returns False.

Args:
repo_dir: the cloned-repo working directory the SDK will use as ``cwd``.
channel_source: inbound channel (``TaskConfig.channel_source``).

Returns:
True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``,
False otherwise (including any non-Linear channel or missing repo_dir).
True if a channel MCP entry was (re)written, False otherwise (channel
unmapped, missing repo_dir, or write failure).
"""
if channel_source != "linear":
builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source)
if builder_entry is None:
return False

server_key, build_entry = builder_entry

if not repo_dir or not os.path.isdir(repo_dir):
log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}")
return False
Expand All @@ -94,19 +159,29 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
servers = config.get("mcpServers")
if not isinstance(servers, dict):
servers = {}
servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry()
servers[server_key] = build_entry()
config["mcpServers"] = servers

try:
with open(mcp_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
f.write("\n")
except OSError as e:
log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}")
log("ERROR", f"Failed to write {channel_source} MCP config to {mcp_path}: {e}")
return False

log(
"TASK",
f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})",
f"{channel_source} MCP configured at {mcp_path} (server key: {server_key})",
)
if channel_source == "jira":
# The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL
# docstring + ADR-015). Log it in-band so a "Failed to connect" line in
# the agent logs isn't mistaken for the cause of a missing comment —
# the live outbound path is the REST shim in jira_reactions.py.
log(
"TASK",
"jira MCP entry is a placeholder and is EXPECTED to fail to connect; "
"outbound Jira comments use the REST shim (jira_reactions.py), not MCP",
)
return True
117 changes: 117 additions & 0 deletions agent/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,123 @@ def _refresh(current: dict) -> dict | None:
return access


def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str:
"""Resolve the Jira Cloud OAuth access token from Secrets Manager.

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, and cache the access_token in
``JIRA_API_TOKEN`` so the agent-side Jira REST calls
(``jira_reactions``) can authorize.

**The agent never refreshes the token.** Unlike Linear, Atlassian
*rotates the refresh_token on every use* — a successful refresh
invalidates the stored refresh_token and returns a new one. The agent
runtime has ``secretsmanager:GetSecretValue`` ONLY (no ``PutSecretValue``;
a compromised agent must not be able to overwrite any tenant's OAuth
bundle), so it cannot persist the rotated token. If the agent refreshed,
it would consume the stored refresh_token, keep the replacement only in
memory for this one task, and leave Secrets Manager holding a dead
refresh_token — the next Lambda/agent resolve would get ``invalid_grant``
and the tenant would require re-onboarding. So we deliberately do NOT
refresh here: the trusted Lambda path (``jira-oauth-resolver.ts``, which
has ``PutSecretValue``) owns all refreshes, and the agent uses whatever
access_token the Lambdas have most-recently written.

If the stored token is already expiring/expired, we fail closed — return
an empty string and let the advisory Jira comments no-op. The
orchestrator resolves (and refreshes) the token just before starting the
session, so in practice the agent reads a freshly-written token with a
full lifetime ahead of it.

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

This function is only called when ``channel_source == 'jira'``.
"""
cached = os.environ.get("JIRA_API_TOKEN", "")
if cached:
return cached

secret_arn = ""
if channel_metadata:
secret_arn = channel_metadata.get("jira_oauth_secret_arn", "")
if not secret_arn:
secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "")
if not secret_arn:
return ""

region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
if not region:
log("WARN", "resolve_jira_oauth_token: AWS_REGION not set; cannot resolve token")
return ""

try:
import json
from datetime import datetime

import boto3
from botocore.exceptions import BotoCoreError, ClientError
except ImportError as e:
log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping")
return ""

sm = boto3.client("secretsmanager", region_name=region)

def _fetch_token() -> dict | None:
resp = sm.get_secret_value(SecretId=secret_arn)
try:
return json.loads(resp["SecretString"])
except (json.JSONDecodeError, KeyError, TypeError) as e:
log(
"ERROR",
f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON "
f"({type(e).__name__}: {e}); tenant requires re-onboarding",
)
return None

def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool:
try:
expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00"))
except ValueError:
log(
"WARN",
f"_is_expiring: malformed expires_at '{expires_at_iso}'; treating as expiring",
)
return True
return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds

try:
token_obj = _fetch_token()
except (ClientError, BotoCoreError) as e:
code = ""
if hasattr(e, "response"):
code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or ""
is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException")
severity = "ERROR" if is_hard_failure else "WARN"
log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}")
return ""
if token_obj is None:
return ""

# Fail closed if the stored token is expiring — the agent cannot refresh
# without burning Atlassian's rotating refresh_token (see docstring). The
# Lambda path owns refresh; advisory Jira comments simply no-op here.
if _is_expiring(token_obj.get("expires_at", "")):
log(
"WARN",
"resolve_jira_oauth_token: stored token is expiring and the agent does not "
"refresh (Atlassian rotates refresh_tokens; agent lacks PutSecretValue). "
"Failing closed — Jira comments will be skipped for this task.",
)
return ""

access = token_obj.get("access_token", "")
if access:
os.environ["JIRA_API_TOKEN"] = access
return access


def build_config(
repo_url: str = "",
task_description: str = "",
Expand Down
Loading
Loading