Skip to content

Commit 6451740

Browse files
committed
feat(agent): remove Linear MCP — Linear is fully deterministic (ADR-016)
Linear MCP proved non-functional via the AgentCore Gateway on a single OAuth app (actor=user data reads error; actor=app can't re-consent an installed app), so drop it entirely. Linear context is now pre-hydrated at the Lambda tier (P4.1 attachments + P4.2 comments) and outbound reactions/state go through direct GraphQL in linear_reactions.py (reads LINEAR_API_TOKEN, independent of this module). - channel_mcp: drop the 'linear' CHANNEL_MCP_BUILDERS entry, _linear_server_entry, _build_linear_entry, and the gateway-routing branch; channel_source='linear' is now a no-op (no .mcp.json written). - The AgentCore Gateway itself is KEPT as the general MCP control plane for other servers (gateway_auth.py M2M mint retained). - Jira MCP placeholder + merge/gate machinery unchanged. prompt_builder still references mcp__linear-server__* — rewritten in P4.4.
1 parent 26aafe7 commit 6451740

2 files changed

Lines changed: 58 additions & 218 deletions

File tree

agent/src/channel_mcp.py

Lines changed: 17 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,28 @@
66
start and exposes the server's tools.
77
88
Currently wired channels:
9-
- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional.
109
- ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It
1110
is written for forward-compatibility but cannot connect from a headless
1211
agent (interactive OAuth 2.1 only); live outbound Jira comments go through
1312
the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015.
1413
14+
Linear is NOT here: ABCA runs Linear 100% deterministically (ADR-016 "Linear
15+
is fully deterministic"). There is no Linear MCP — issue text, recent comments,
16+
and attachments are pre-hydrated at the Lambda tier (the webhook processor +
17+
``linear-attachments.ts`` / ``linear-feedback.fetchRecentComments``), and
18+
outbound reactions / state transitions go through direct GraphQL in
19+
``linear_reactions.py`` (which reads ``LINEAR_API_TOKEN`` set by config.py —
20+
independent of this module). Linear MCP via the AgentCore Gateway was removed
21+
after it proved non-functional on a single OAuth app (actor=user data reads
22+
error; actor=app can't re-consent an installed app). The Gateway itself
23+
remains ABCA's general MCP control plane for OTHER servers — see
24+
``gateway_auth.py`` for the reusable M2M bearer mint.
25+
1526
For all other channel sources this is a no-op: no MCP is written, and the
1627
SDK sees no channel-specific tools.
1728
1829
See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound),
19-
runner.py (SDK invocation).
30+
runner.py (SDK invocation), ADR-016 (Linear determinism).
2031
"""
2132

2233
from __future__ import annotations
@@ -30,55 +41,6 @@
3041
if TYPE_CHECKING:
3142
from collections.abc import Callable
3243

33-
# ─── Linear ──────────────────────────────────────────────────────────────────
34-
35-
#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport.
36-
LINEAR_MCP_URL = "https://mcp.linear.app/mcp"
37-
38-
#: Key name inside ``mcpServers``. Tools surface as
39-
#: ``mcp__linear-server__*`` in the Agent SDK.
40-
LINEAR_MCP_SERVER_KEY = "linear-server"
41-
42-
#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}``
43-
#: placeholder expansion. Populated from the OAuth secret by config.py.
44-
LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
45-
46-
#: channel_metadata key carrying the per-workspace AgentCore Gateway MCP URL
47-
#: (stamped by the orchestrator from the LinearWorkspaceRegistry row when the
48-
#: workspace was onboarded with `bgagent linear add-workspace --gateway`).
49-
GATEWAY_URL_METADATA_KEY = "gateway_url"
50-
51-
52-
def _linear_server_entry(gateway_url: str = "", gateway_bearer: str = "") -> dict[str, Any]:
53-
"""Build the `mcpServers` entry for the Linear MCP.
54-
55-
Two modes:
56-
* **Gateway federation** (``gateway_url`` set) — point at the workspace's
57-
AgentCore Gateway endpoint, authenticating with the M2M bearer the agent
58-
minted (the gateway's CUSTOM_JWT inbound). The gateway holds the Linear
59-
OAuth token + owns its 24h refresh, so no per-thread Linear token is
60-
injected into the container. See docs/design/AGENTCORE_GATEWAY_MCP_SPIKE.md.
61-
* **Direct** (default) — point at Linear's hosted MCP with the per-thread
62-
``${LINEAR_API_TOKEN}`` bearer (resolved by config.py from the OAuth
63-
secret). The pre-gateway path; unchanged fallback.
64-
"""
65-
if gateway_url and gateway_bearer:
66-
return {
67-
"type": "http",
68-
"url": gateway_url,
69-
"headers": {
70-
"Authorization": f"Bearer {gateway_bearer}",
71-
},
72-
}
73-
return {
74-
"type": "http",
75-
"url": LINEAR_MCP_URL,
76-
"headers": {
77-
"Authorization": f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}",
78-
},
79-
}
80-
81-
8244
# ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ────────────────
8345

8446
#: Atlassian Remote MCP endpoint — Streamable HTTP transport.
@@ -125,7 +87,6 @@ def _jira_server_entry() -> dict[str, Any]:
12587
#: have a hosted MCP (api, webhook, slack) intentionally have no entry here —
12688
#: the gate in ``configure_channel_mcp`` short-circuits on missing keys.
12789
CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
128-
"linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry),
12990
"jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry),
13091
}
13192

@@ -150,29 +111,6 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]:
150111
return {}
151112

152113

153-
def _build_linear_entry(channel_metadata: dict[str, str] | None) -> dict[str, Any]:
154-
"""Build the Linear ``mcpServers`` entry, routing through the per-workspace
155-
AgentCore Gateway when the workspace was onboarded with one.
156-
157-
The orchestrator stamps ``gateway_url`` into channel_metadata from the
158-
workspace's registry row. When present, mint the M2M bearer and point the
159-
entry at the gateway; otherwise fall back to the direct Linear MCP path.
160-
"""
161-
gateway_url = (channel_metadata or {}).get(GATEWAY_URL_METADATA_KEY, "")
162-
if gateway_url:
163-
from gateway_auth import get_gateway_bearer_token
164-
165-
bearer = get_gateway_bearer_token()
166-
if bearer:
167-
log("TASK", f"Linear MCP routed through AgentCore Gateway ({gateway_url})")
168-
return _linear_server_entry(gateway_url=gateway_url, gateway_bearer=bearer)
169-
# Gateway configured for the workspace but the token mint failed — do
170-
# NOT silently fall back to the direct path (the per-thread LINEAR_API_
171-
# TOKEN may not even be set once a workspace is gateway-managed). Surface it.
172-
log("WARN", "Linear gateway_url present but M2M token mint failed; using direct MCP path")
173-
return _linear_server_entry()
174-
175-
176114
def configure_channel_mcp(
177115
repo_dir: str,
178116
channel_source: str,
@@ -190,13 +128,14 @@ def configure_channel_mcp(
190128
repo_dir: the cloned-repo working directory the SDK will use as ``cwd``.
191129
channel_source: inbound channel (``TaskConfig.channel_source``).
192130
channel_metadata: per-task channel metadata (``TaskConfig.channel_metadata``).
193-
For Linear, a ``gateway_url`` here routes the MCP through the workspace's
194-
AgentCore Gateway instead of the direct hosted endpoint.
131+
Currently unused by the wired channels (Jira's entry is static); retained
132+
for call-site compatibility and future channel builders that need it.
195133
196134
Returns:
197135
True if a channel MCP entry was (re)written, False otherwise (channel
198136
unmapped, missing repo_dir, or write failure).
199137
"""
138+
_ = channel_metadata # reserved for future channel builders (see docstring)
200139
builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source)
201140
if builder_entry is None:
202141
return False
@@ -213,12 +152,7 @@ def configure_channel_mcp(
213152
servers = config.get("mcpServers")
214153
if not isinstance(servers, dict):
215154
servers = {}
216-
# Linear supports gateway federation (channel_metadata-driven); other
217-
# channels use their static zero-arg builder.
218-
if channel_source == "linear":
219-
servers[server_key] = _build_linear_entry(channel_metadata)
220-
else:
221-
servers[server_key] = build_entry()
155+
servers[server_key] = build_entry()
222156
config["mcpServers"] = servers
223157

224158
try:

agent/tests/test_channel_mcp.py

Lines changed: 41 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
"""Unit tests for channel_mcp.configure_channel_mcp — Linear/Jira MCP gating + merge."""
1+
"""Unit tests for channel_mcp.configure_channel_mcp — Jira MCP gating + merge.
2+
3+
Linear is NOT tested here: ABCA runs Linear 100% deterministically (ADR-016),
4+
so there is no Linear MCP entry. The gate below asserts that channel_source=='linear'
5+
is now a no-op (no .mcp.json written).
6+
"""
27

38
from __future__ import annotations
49

@@ -9,9 +14,6 @@
914
JIRA_API_TOKEN_ENV,
1015
JIRA_MCP_SERVER_KEY,
1116
JIRA_MCP_URL,
12-
LINEAR_API_TOKEN_ENV,
13-
LINEAR_MCP_SERVER_KEY,
14-
LINEAR_MCP_URL,
1517
configure_channel_mcp,
1618
)
1719

@@ -23,7 +25,23 @@ def _read_mcp(repo_dir: str) -> dict:
2325

2426

2527
class TestChannelGate:
26-
"""Only channel_source=='linear' writes anything — everything else is a no-op."""
28+
"""Only channel_source with a wired MCP writes anything — everything else is a no-op."""
29+
30+
def test_no_op_for_linear_channel(self, tmp_path):
31+
# ADR-016: Linear is fully deterministic — no Linear MCP is written.
32+
wrote = configure_channel_mcp(str(tmp_path), "linear")
33+
assert wrote is False
34+
assert not (tmp_path / ".mcp.json").exists()
35+
36+
def test_no_op_for_linear_channel_ignores_gateway_url(self, tmp_path):
37+
# A stale gateway_url in metadata must not resurrect a Linear MCP entry.
38+
wrote = configure_channel_mcp(
39+
str(tmp_path),
40+
"linear",
41+
{"gateway_url": "https://gw.example/mcp"},
42+
)
43+
assert wrote is False
44+
assert not (tmp_path / ".mcp.json").exists()
2745

2846
def test_no_op_for_slack_channel(self, tmp_path):
2947
wrote = configure_channel_mcp(str(tmp_path), "slack")
@@ -46,101 +64,16 @@ def test_no_op_for_empty_channel(self, tmp_path):
4664
assert not (tmp_path / ".mcp.json").exists()
4765

4866

49-
class TestLinearWrite:
50-
"""channel_source=='linear' writes .mcp.json with the linear-server entry."""
51-
52-
def test_creates_mcp_json_with_linear_server_key(self, tmp_path):
53-
wrote = configure_channel_mcp(str(tmp_path), "linear")
54-
assert wrote is True
55-
config = _read_mcp(str(tmp_path))
56-
assert LINEAR_MCP_SERVER_KEY in config["mcpServers"]
57-
58-
def test_renders_linear_url_and_token_placeholder(self, tmp_path):
59-
configure_channel_mcp(str(tmp_path), "linear")
60-
entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY]
61-
assert entry["type"] == "http"
62-
assert entry["url"] == LINEAR_MCP_URL
63-
assert entry["headers"]["Authorization"] == f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}"
64-
65-
def test_server_key_is_linear_server(self):
66-
# If this ever changes, tools surface under a different mcp__ prefix and
67-
# the agent prompt (prompt_builder._channel_prompt_addendum) must be
68-
# updated in lockstep.
69-
assert LINEAR_MCP_SERVER_KEY == "linear-server"
70-
71-
72-
class TestMerge:
73-
"""Existing .mcp.json must not be clobbered."""
74-
75-
def test_adds_linear_to_existing_empty_mcp_json(self, tmp_path):
76-
(tmp_path / ".mcp.json").write_text("{}")
77-
wrote = configure_channel_mcp(str(tmp_path), "linear")
78-
assert wrote is True
79-
assert LINEAR_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"]
80-
81-
def test_preserves_existing_mcp_servers(self, tmp_path):
82-
existing = {
83-
"mcpServers": {
84-
"other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"},
85-
},
86-
}
87-
(tmp_path / ".mcp.json").write_text(json.dumps(existing))
88-
89-
configure_channel_mcp(str(tmp_path), "linear")
90-
merged = _read_mcp(str(tmp_path))
91-
assert "other-server" in merged["mcpServers"]
92-
assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp"
93-
assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"]
94-
95-
def test_overwrites_existing_linear_server_entry(self, tmp_path):
96-
# If someone committed a stale Linear entry with a wrong token var, we
97-
# want the fresh ABCA-written entry to win — otherwise the MCP would
98-
# fail to auth.
99-
existing = {
100-
"mcpServers": {
101-
LINEAR_MCP_SERVER_KEY: {
102-
"type": "http",
103-
"url": "https://stale.example",
104-
"headers": {"Authorization": "Bearer stale"},
105-
},
106-
},
107-
}
108-
(tmp_path / ".mcp.json").write_text(json.dumps(existing))
109-
110-
configure_channel_mcp(str(tmp_path), "linear")
111-
entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY]
112-
assert entry["url"] == LINEAR_MCP_URL
113-
assert "stale" not in entry["headers"]["Authorization"]
114-
115-
def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path):
116-
# A .mcp.json that only has unrelated top-level keys should still
117-
# gain an mcpServers map.
118-
(tmp_path / ".mcp.json").write_text(json.dumps({"version": 1}))
119-
configure_channel_mcp(str(tmp_path), "linear")
120-
merged = _read_mcp(str(tmp_path))
121-
assert merged["version"] == 1
122-
assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"]
123-
124-
def test_malformed_mcp_json_is_replaced(self, tmp_path):
125-
# Malformed JSON is treated as absent (logged as a warning in shell.log)
126-
# rather than crashing the pipeline.
127-
(tmp_path / ".mcp.json").write_text("{not json")
128-
wrote = configure_channel_mcp(str(tmp_path), "linear")
129-
assert wrote is True
130-
merged = _read_mcp(str(tmp_path))
131-
assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"]
132-
133-
13467
class TestRepoDirGuard:
13568
"""Missing repo_dir must not raise — the pipeline should keep going."""
13669

13770
def test_missing_repo_dir(self, tmp_path):
13871
missing = tmp_path / "does-not-exist"
139-
wrote = configure_channel_mcp(str(missing), "linear")
72+
wrote = configure_channel_mcp(str(missing), "jira")
14073
assert wrote is False
14174

14275
def test_empty_repo_dir_string(self):
143-
wrote = configure_channel_mcp("", "linear")
76+
wrote = configure_channel_mcp("", "jira")
14477
assert wrote is False
14578

14679

@@ -169,6 +102,12 @@ def test_server_key_is_jira_server(self):
169102
class TestJiraMerge:
170103
"""Jira entry must coexist with other servers and overwrite stale jira entries."""
171104

105+
def test_adds_jira_to_existing_empty_mcp_json(self, tmp_path):
106+
(tmp_path / ".mcp.json").write_text("{}")
107+
wrote = configure_channel_mcp(str(tmp_path), "jira")
108+
assert wrote is True
109+
assert JIRA_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"]
110+
172111
def test_preserves_existing_mcp_servers(self, tmp_path):
173112
existing = {
174113
"mcpServers": {
@@ -200,51 +139,18 @@ def test_overwrites_existing_jira_server_entry(self, tmp_path):
200139
assert entry["url"] == JIRA_MCP_URL
201140
assert "stale" not in entry["headers"]["Authorization"]
202141

203-
def test_linear_and_jira_can_coexist(self, tmp_path):
204-
# Belt-and-braces: a repo that committed a Linear entry and then
205-
# gets onboarded to Jira (or vice-versa) must keep both. The current
206-
# code path only writes one channel per run, but this test guards
207-
# against a future refactor that writes the wrong key.
208-
configure_channel_mcp(str(tmp_path), "linear")
142+
def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path):
143+
(tmp_path / ".mcp.json").write_text(json.dumps({"version": 1}))
209144
configure_channel_mcp(str(tmp_path), "jira")
210145
merged = _read_mcp(str(tmp_path))
211-
assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"]
146+
assert merged["version"] == 1
212147
assert JIRA_MCP_SERVER_KEY in merged["mcpServers"]
213148

214-
215-
class TestLinearGatewayRouting:
216-
"""channel_metadata.gateway_url routes the Linear MCP through the workspace
217-
gateway (F15/F16); absence keeps the direct hosted path."""
218-
219-
def _entry(self, tmp_path):
220-
from channel_mcp import LINEAR_MCP_SERVER_KEY
221-
222-
return _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY]
223-
224-
def test_routes_through_gateway_when_url_and_token_present(self, tmp_path, monkeypatch):
225-
# _build_linear_entry imports gateway_auth lazily, so patch it there.
226-
monkeypatch.setattr("gateway_auth.get_gateway_bearer_token", lambda: "m2m-token-xyz")
227-
gw = "https://gw-abc.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp"
228-
wrote = configure_channel_mcp(str(tmp_path), "linear", {"gateway_url": gw})
149+
def test_malformed_mcp_json_is_replaced(self, tmp_path):
150+
# Malformed JSON is treated as absent (logged as a warning in shell.log)
151+
# rather than crashing the pipeline.
152+
(tmp_path / ".mcp.json").write_text("{not json")
153+
wrote = configure_channel_mcp(str(tmp_path), "jira")
229154
assert wrote is True
230-
entry = self._entry(tmp_path)
231-
assert entry["url"] == gw
232-
assert entry["headers"]["Authorization"] == "Bearer m2m-token-xyz"
233-
234-
def test_falls_back_to_direct_when_no_gateway_url(self, tmp_path):
235-
configure_channel_mcp(str(tmp_path), "linear", {"linear_oauth_secret_arn": "arn:x"})
236-
entry = self._entry(tmp_path)
237-
assert entry["url"] == LINEAR_MCP_URL
238-
assert entry["headers"]["Authorization"] == f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}"
239-
240-
def test_falls_back_to_direct_when_token_mint_fails(self, tmp_path, monkeypatch):
241-
# gateway_url present but the M2M token mint returns "" → direct path.
242-
monkeypatch.setattr("gateway_auth.get_gateway_bearer_token", lambda: "")
243-
gw = "https://gw-abc.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp"
244-
configure_channel_mcp(str(tmp_path), "linear", {"gateway_url": gw})
245-
entry = self._entry(tmp_path)
246-
assert entry["url"] == LINEAR_MCP_URL
247-
248-
def test_none_metadata_is_direct_path(self, tmp_path):
249-
configure_channel_mcp(str(tmp_path), "linear", None)
250-
assert self._entry(tmp_path)["url"] == LINEAR_MCP_URL
155+
merged = _read_mcp(str(tmp_path))
156+
assert JIRA_MCP_SERVER_KEY in merged["mcpServers"]

0 commit comments

Comments
 (0)