11"""Channel-specific MCP configuration for the agent container.
22
3- For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned
4- repo ``cwd`` so the Claude Agent SDK — configured with
5- ``setting_sources=["project"]`` — picks up the Linear MCP at session start
6- and exposes ``mcp__linear-server__*`` tools.
3+ For inbound channel sources that have a hosted MCP we write (or merge into)
4+ ``.mcp.json`` in the cloned repo ``cwd`` so the Claude Agent SDK — configured
5+ with ``setting_sources=["project"]`` — picks up the channel MCP at session
6+ start and exposes the server's tools.
7+
8+ Currently wired channels:
9+ - ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional.
10+ - ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It
11+ is written for forward-compatibility but cannot connect from a headless
12+ agent (interactive OAuth 2.1 only); live outbound Jira comments go through
13+ the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015.
714
815For all other channel sources this is a no-op: no MCP is written, and the
9- SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks
10- from touching Linear.
16+ SDK sees no channel-specific tools.
1117
12- See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py
13- (SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md .
18+ See: cdk/src/handlers/{ linear,jira} -webhook-processor.ts (inbound),
19+ runner.py (SDK invocation).
1420"""
1521
1622from __future__ import annotations
1723
1824import json
1925import os
20- from typing import Any
26+ from typing import TYPE_CHECKING , Any
2127
2228from shell import log
2329
30+ if TYPE_CHECKING :
31+ from collections .abc import Callable
32+
33+ # ─── Linear ──────────────────────────────────────────────────────────────────
34+
2435#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport.
2536LINEAR_MCP_URL = "https://mcp.linear.app/mcp"
2637
2738#: Key name inside ``mcpServers``. Tools surface as
28- #: ``mcp__linear-server__*`` in the Agent SDK (verified in findings) .
39+ #: ``mcp__linear-server__*`` in the Agent SDK.
2940LINEAR_MCP_SERVER_KEY = "linear-server"
3041
3142#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}``
32- #: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh .
43+ #: placeholder expansion. Populated from the OAuth secret by config.py .
3344LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
3445
3546
@@ -44,11 +55,62 @@ def _linear_server_entry() -> dict[str, Any]:
4455 }
4556
4657
58+ # ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ────────────────
59+
60+ #: Atlassian Remote MCP endpoint — Streamable HTTP transport.
61+ #:
62+ #: IMPORTANT: this entry does NOT work from a headless agent and is retained
63+ #: only as a forward-looking placeholder. The hosted Atlassian MCP requires an
64+ #: interactive, browser-based OAuth 2.1 flow with dynamic client registration
65+ #: and will NOT accept the stored REST OAuth token as a Bearer header, so it
66+ #: fails to connect in the runtime (``claude mcp list`` → "Failed to connect").
67+ #:
68+ #: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py``
69+ #: (the "Plan B" that became Plan A), which posts comments via the Jira REST
70+ #: v3 API using the same stored OAuth token. See ADR-015 and
71+ #: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible
72+ #: MCP, this entry can be promoted and the REST shim retired.
73+ JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse"
74+
75+ #: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*``
76+ #: in the Agent SDK. If this changes the agent prompt's channel addendum
77+ #: must be updated in lockstep.
78+ JIRA_MCP_SERVER_KEY = "jira-server"
79+
80+ #: Env var name the Jira MCP server entry reads via ``${JIRA_API_TOKEN}``
81+ #: placeholder expansion. Populated from the per-tenant OAuth secret by
82+ #: config.resolve_jira_oauth_token.
83+ JIRA_API_TOKEN_ENV = "JIRA_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
84+
85+
86+ def _jira_server_entry () -> dict [str , Any ]:
87+ """Build the `mcpServers` entry for Atlassian's Remote MCP."""
88+ return {
89+ "type" : "http" ,
90+ "url" : JIRA_MCP_URL ,
91+ "headers" : {
92+ "Authorization" : f"Bearer ${{{ JIRA_API_TOKEN_ENV } }}" ,
93+ },
94+ }
95+
96+
97+ # ─── Dispatch ────────────────────────────────────────────────────────────────
98+
99+ #: Per-channel ``mcpServers`` entry builder. The channel_source values mirror
100+ #: ``ChannelSource`` in cdk/src/handlers/shared/types.ts. Sources that don't
101+ #: have a hosted MCP (api, webhook, slack) intentionally have no entry here —
102+ #: the gate in ``configure_channel_mcp`` short-circuits on missing keys.
103+ CHANNEL_MCP_BUILDERS : dict [str , tuple [str , Callable [[], dict [str , Any ]]]] = {
104+ "linear" : (LINEAR_MCP_SERVER_KEY , _linear_server_entry ),
105+ "jira" : (JIRA_MCP_SERVER_KEY , _jira_server_entry ),
106+ }
107+
108+
47109def _read_existing_mcp_config (path : str ) -> dict [str , Any ]:
48110 """Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid.
49111
50112 Malformed JSON is logged and treated as absent — we prefer to overlay a
51- valid Linear entry than to crash the agent because a user committed a
113+ valid channel entry than to crash the agent because a user committed a
52114 broken .mcp.json to their repo.
53115 """
54116 if not os .path .isfile (path ):
@@ -67,23 +129,26 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]:
67129def configure_channel_mcp (repo_dir : str , channel_source : str ) -> bool :
68130 """Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``.
69131
70- Gated on ``channel_source``:
71- * ``'linear'`` → ensure the ``linear-server `` entry is present in
132+ Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS `:
133+ * present → ensure the corresponding ``mcpServers `` entry is in
72134 ``.mcp.json`` (merges into any existing config without clobbering
73135 other servers). Returns True.
74- * anything else → no-op. Returns False.
136+ * absent → no-op. Returns False.
75137
76138 Args:
77139 repo_dir: the cloned-repo working directory the SDK will use as ``cwd``.
78140 channel_source: inbound channel (``TaskConfig.channel_source``).
79141
80142 Returns:
81- True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``,
82- False otherwise (including any non-Linear channel or missing repo_dir ).
143+ True if a channel MCP entry was (re)written, False otherwise (channel
144+ unmapped, missing repo_dir, or write failure ).
83145 """
84- if channel_source != "linear" :
146+ builder_entry = CHANNEL_MCP_BUILDERS .get (channel_source )
147+ if builder_entry is None :
85148 return False
86149
150+ server_key , build_entry = builder_entry
151+
87152 if not repo_dir or not os .path .isdir (repo_dir ):
88153 log ("WARN" , f"configure_channel_mcp: repo_dir missing or not a directory: { repo_dir !r} " )
89154 return False
@@ -94,19 +159,29 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
94159 servers = config .get ("mcpServers" )
95160 if not isinstance (servers , dict ):
96161 servers = {}
97- servers [LINEAR_MCP_SERVER_KEY ] = _linear_server_entry ()
162+ servers [server_key ] = build_entry ()
98163 config ["mcpServers" ] = servers
99164
100165 try :
101166 with open (mcp_path , "w" , encoding = "utf-8" ) as f :
102167 json .dump (config , f , indent = 2 )
103168 f .write ("\n " )
104169 except OSError as e :
105- log ("ERROR" , f"Failed to write Linear MCP config to { mcp_path } : { e } " )
170+ log ("ERROR" , f"Failed to write { channel_source } MCP config to { mcp_path } : { e } " )
106171 return False
107172
108173 log (
109174 "TASK" ,
110- f"Linear MCP configured at { mcp_path } (server key: { LINEAR_MCP_SERVER_KEY } )" ,
175+ f"{ channel_source } MCP configured at { mcp_path } (server key: { server_key } )" ,
111176 )
177+ if channel_source == "jira" :
178+ # The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL
179+ # docstring + ADR-015). Log it in-band so a "Failed to connect" line in
180+ # the agent logs isn't mistaken for the cause of a missing comment —
181+ # the live outbound path is the REST shim in jira_reactions.py.
182+ log (
183+ "TASK" ,
184+ "jira MCP entry is a placeholder and is EXPECTED to fail to connect; "
185+ "outbound Jira comments use the REST shim (jira_reactions.py), not MCP" ,
186+ )
112187 return True
0 commit comments