Skip to content

Commit a213816

Browse files
authored
fix(jira): filter Cloud-only tools on Server/DC (#1603)
Reported-by: AmirF194 Github-Issue: #1082
1 parent 09703a0 commit a213816

3 files changed

Lines changed: 120 additions & 9 deletions

File tree

src/mcp_atlassian/servers/jira.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,7 +1899,7 @@ async def batch_create_issues(
18991899

19001900

19011901
@jira_mcp.tool(
1902-
tags={"jira", "read", "toolset:jira_issues"},
1902+
tags={"jira", "read", "cloud_only", "toolset:jira_issues"},
19031903
annotations={"title": "Batch Get Changelogs", "readOnlyHint": True},
19041904
)
19051905
async def batch_get_changelogs(
@@ -2385,7 +2385,7 @@ async def delete_issue(
23852385

23862386

23872387
@jira_mcp.tool(
2388-
tags={"jira", "write", "toolset:jira_issues"},
2388+
tags={"jira", "write", "cloud_only", "toolset:jira_issues"},
23892389
annotations={"title": "Move Issue to Project", "destructiveHint": True},
23902390
)
23912391
@check_write_access

src/mcp_atlassian/servers/main.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,18 +259,33 @@ def _tool_filter_context(self) -> dict[str, Any] | None:
259259
)
260260

261261
header_based_services = {"jira": False, "confluence": False}
262+
service_headers: dict[str, str] = {}
262263
request = getattr(req_context, "request", None)
263264
if request is not None:
264-
service_headers = getattr(request.state, "atlassian_service_headers", {})
265-
if service_headers:
265+
request_service_headers = getattr(
266+
request.state, "atlassian_service_headers", {}
267+
)
268+
if isinstance(request_service_headers, dict) and request_service_headers:
269+
service_headers = request_service_headers
266270
header_based_services = get_available_services(service_headers)
267271

272+
jira_config = (
273+
app_lifespan_state.full_jira_config if app_lifespan_state else None
274+
)
275+
jira_url_header = service_headers.get("X-Atlassian-Jira-Url")
276+
jira_is_cloud: bool | None = None
277+
if jira_url_header:
278+
jira_is_cloud = is_atlassian_cloud_url(jira_url_header)
279+
elif jira_config is not None:
280+
jira_is_cloud = bool(jira_config.is_cloud)
281+
268282
return {
269283
"read_only": read_only,
270284
"enabled_tools_filter": enabled_tools_filter,
271285
"enabled_toolsets_filter": enabled_toolsets_filter,
272286
"app_lifespan_state": app_lifespan_state,
273287
"header_based_services": header_based_services,
288+
"jira_is_cloud": jira_is_cloud,
274289
}
275290

276291
def _is_tool_authorized(
@@ -294,13 +309,25 @@ def _is_tool_authorized(
294309
return False
295310
return True
296311

312+
@staticmethod
313+
def _is_tool_supported_on_deployment(
314+
tool_obj: FastMCPTool, ctx: dict[str, Any]
315+
) -> bool:
316+
"""Return whether a tool is supported by the configured deployment."""
317+
tool_tags = tool_obj.tags
318+
if "cloud_only" in tool_tags and "jira" in tool_tags:
319+
return ctx["jira_is_cloud"] is not False
320+
return True
321+
297322
def _is_tool_enabled(
298323
self, registered_name: str, tool_obj: FastMCPTool, ctx: dict[str, Any]
299324
) -> bool:
300325
"""Listing filter: the tool is authorized AND its backing service is
301326
configured/available (the latter is a listing-only graceful-hide)."""
302327
if not self._is_tool_authorized(registered_name, tool_obj, ctx):
303328
return False
329+
if not self._is_tool_supported_on_deployment(tool_obj, ctx):
330+
return False
304331

305332
app_lifespan_state = ctx["app_lifespan_state"]
306333
header_based_services = ctx["header_based_services"]
@@ -361,14 +388,18 @@ async def _list_tools_mcp(self) -> list[MCPTool]:
361388
async def _call_tool_mcp(self, key: str, arguments: dict[str, Any]) -> Any:
362389
# Enforce the same enablement filter at call time as at listing time, so a
363390
# tool hidden from the listing (read-only mode, not in ENABLED_TOOLS, toolset
364-
# disabled, or service unavailable) cannot be invoked directly by name.
391+
# disabled, or deployment-incompatible) cannot be invoked directly by name.
365392
# Under an active filter context, denials and genuinely unknown tools raise
366393
# byte-identical messages here (no exists-but-disabled leak), decoupled from
367394
# upstream's error format (FastMCP uses a repr-quoted name).
368395
ctx = self._tool_filter_context()
369396
if ctx is not None:
370397
tool_obj = await self.get_tool(key)
371-
if tool_obj is None or not self._is_tool_authorized(key, tool_obj, ctx):
398+
if (
399+
tool_obj is None
400+
or not self._is_tool_authorized(key, tool_obj, ctx)
401+
or not self._is_tool_supported_on_deployment(tool_obj, ctx)
402+
):
372403
raise NotFoundError(f"Unknown tool: {key}")
373404
return await super()._call_tool_mcp(key, arguments)
374405

tests/unit/servers/test_mcp_protocol.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
UserTokenMiddleware,
2727
health_check,
2828
main_lifespan,
29+
main_mcp,
2930
)
3031
from tests.utils.factories import (
3132
ConfluencePageFactory,
@@ -35,6 +36,23 @@
3536

3637
logger = logging.getLogger(__name__)
3738

39+
JIRA_CLOUD_ONLY_TOOL_NAMES = {
40+
"batch_get_changelogs",
41+
"move_issue",
42+
}
43+
44+
45+
def _mock_tool(name, tags):
46+
tool = MagicMock(spec=FastMCPTool)
47+
tool.name = name
48+
tool.tags = tags
49+
tool.to_mcp_tool.return_value = MCPTool(
50+
name=name,
51+
description=f"Tool {name}",
52+
inputSchema={"type": "object", "properties": {}},
53+
)
54+
return tool
55+
3856

3957
@pytest.mark.anyio
4058
class TestMCPProtocolIntegration:
@@ -100,15 +118,16 @@ async def test_call_tool_mcp_enforces_enablement_at_dispatch(
100118
"""A tool filtered out of the listing must not be invocable by name.
101119
102120
`_list_tools_mcp` hides read-only-excluded / non-enabled / disabled-toolset
103-
tools, but the call path must re-check — otherwise a client can dispatch a
104-
hidden tool directly by name. Verifies denied calls never reach the
105-
underlying executor and enabled calls do (GHSA-3r68).
121+
/ deployment-incompatible tools, but the call path must re-check — otherwise
122+
a client can dispatch a hidden tool directly by name. Verifies denied calls
123+
never reach the underlying executor and enabled calls do (GHSA-3r68).
106124
"""
107125
from unittest.mock import AsyncMock
108126

109127
from fastmcp import FastMCP
110128
from fastmcp.exceptions import NotFoundError
111129

130+
mock_jira_config.is_cloud = False
112131
app_context = MainAppContext(
113132
full_jira_config=mock_jira_config,
114133
full_confluence_config=mock_confluence_config,
@@ -125,9 +144,12 @@ async def test_call_tool_mcp_enforces_enablement_at_dispatch(
125144
read_tool.tags = {"jira", "read"}
126145
write_tool = MagicMock(spec=FastMCPTool)
127146
write_tool.tags = {"jira", "write"}
147+
cloud_only_tool = MagicMock(spec=FastMCPTool)
148+
cloud_only_tool.tags = {"jira", "read", "cloud_only"}
128149
tools_by_name = {
129150
"jira_get_issue": read_tool,
130151
"jira_create_issue": write_tool,
152+
"jira_batch_get_changelogs": cloud_only_tool,
131153
}
132154

133155
async def mock_get_tool(name, version=None):
@@ -145,6 +167,13 @@ async def mock_get_tool(name, version=None):
145167
await atlassian_mcp_server._call_tool_mcp("jira_create_issue", {})
146168
mock_super.assert_not_called()
147169

170+
# Cloud-only tool is deployment-excluded on Server/DC -> denied.
171+
with pytest.raises(NotFoundError) as deployment_exc:
172+
await atlassian_mcp_server._call_tool_mcp(
173+
"jira_batch_get_changelogs", {}
174+
)
175+
mock_super.assert_not_called()
176+
148177
# Genuinely unknown tool -> denied by our override too (never reaches
149178
# super, whose repr-quoted message would leak tool existence).
150179
with pytest.raises(NotFoundError) as unknown_exc:
@@ -154,13 +183,64 @@ async def mock_get_tool(name, version=None):
154183
# Message parity: hidden-but-existing and genuinely unknown tools
155184
# produce the same unquoted format (no exists-but-disabled leak).
156185
assert str(denied_exc.value) == "Unknown tool: jira_create_issue"
186+
assert (
187+
str(deployment_exc.value) == "Unknown tool: jira_batch_get_changelogs"
188+
)
157189
assert str(unknown_exc.value) == "Unknown tool: jira_no_such_tool"
158190

159191
# Read tool is enabled -> passes the gate and reaches the executor.
160192
result = await atlassian_mcp_server._call_tool_mcp("jira_get_issue", {})
161193
assert result == "EXECUTED"
162194
mock_super.assert_called_once()
163195

196+
@pytest.mark.parametrize("is_cloud", [True, False], ids=["cloud", "server_dc"])
197+
async def test_tool_filtering_by_jira_deployment(self, is_cloud):
198+
"""The production server advertises Cloud-only Jira tools only on Cloud."""
199+
jira_config = MagicMock(spec=JiraConfig)
200+
jira_config.is_cloud = is_cloud
201+
app_context = MainAppContext(full_jira_config=jira_config)
202+
request_context = MagicMock()
203+
request_context.request = None
204+
request_context.lifespan_context = {"app_lifespan_context": app_context}
205+
206+
with patch.object(main_mcp, "_mcp_server") as mcp_server:
207+
mcp_server.request_context = request_context
208+
listed_tool_names = {tool.name for tool in await main_mcp._list_tools_mcp()}
209+
210+
expected_cloud_only_tools = {
211+
f"jira_{name}" for name in JIRA_CLOUD_ONLY_TOOL_NAMES
212+
}
213+
assert "jira_get_issue" in listed_tool_names
214+
assert listed_tool_names & expected_cloud_only_tools == (
215+
expected_cloud_only_tools if is_cloud else set()
216+
)
217+
218+
async def test_tool_filtering_uses_header_based_jira_deployment(
219+
self, atlassian_mcp_server
220+
):
221+
"""Per-request Jira URLs determine Cloud-only tool availability."""
222+
app_context = MainAppContext()
223+
request_context = MagicMock()
224+
request_context.lifespan_context = {"app_lifespan_context": app_context}
225+
request_context.request.state.atlassian_service_headers = {
226+
"X-Atlassian-Jira-Personal-Token": "test-token",
227+
"X-Atlassian-Jira-Url": "https://jira.example.com",
228+
}
229+
atlassian_mcp_server._mcp_server = MagicMock()
230+
atlassian_mcp_server._mcp_server.request_context = request_context
231+
232+
tool = _mock_tool("jira_batch_get_changelogs", {"jira", "read", "cloud_only"})
233+
234+
async def mock_list_tools():
235+
return [tool]
236+
237+
atlassian_mcp_server.list_tools = mock_list_tools
238+
239+
with MockEnvironment.clean_env():
240+
tools = await atlassian_mcp_server._list_tools_mcp()
241+
242+
assert tools == []
243+
164244
async def test_tool_discovery_with_full_configuration(
165245
self, atlassian_mcp_server, mock_jira_config, mock_confluence_config
166246
):

0 commit comments

Comments
 (0)