2626 UserTokenMiddleware ,
2727 health_check ,
2828 main_lifespan ,
29+ main_mcp ,
2930)
3031from tests .utils .factories import (
3132 ConfluencePageFactory ,
3536
3637logger = 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
4058class 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