Skip to content

Commit 48ad92b

Browse files
committed
test(fetch_tools): rewrite tests to use real MCP mock server
Replace monkeypatch-based tests with integration tests using the actual MCP protocol via the Hono mock server. This provides more realistic coverage of the fetch_tools() implementation. Changes: - Rename test_toolset_mcp.py to test_fetch_tools.py for clarity - Use mcp_mock_server fixture for most tests (real MCP protocol) - Keep monkeypatch tests only for schema normalisation logic - Add integration marker for MCP server tests - Add tests for MCP headers, tool creation, and RPC execution The tests now exercise the full MCP client flow including _fetch_mcp_tools(), header building, and tool response parsing.
1 parent 4919a31 commit 48ad92b

File tree

3 files changed

+297
-272
lines changed

3 files changed

+297
-272
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ asyncio_mode = "strict"
7272
asyncio_default_fixture_loop_scope = "function"
7373
markers = [
7474
"asyncio: mark test as async",
75+
"integration: mark test as integration test requiring MCP mock server",
7576
]
7677

7778
[tool.ruff.lint.per-file-ignores]

tests/test_fetch_tools.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"""Tests for StackOneToolSet MCP functionality using real MCP mock server."""
2+
3+
from __future__ import annotations
4+
5+
from stackone_ai.toolset import StackOneToolSet
6+
7+
8+
class TestAccountFiltering:
9+
"""Test account filtering functionality with real MCP server."""
10+
11+
def test_set_accounts_chaining(self, mcp_mock_server: str):
12+
"""Test that setAccounts() returns self for chaining"""
13+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
14+
result = toolset.set_accounts(["acc1", "acc2"])
15+
assert result is toolset
16+
17+
def test_fetch_tools_without_account_filtering(self, mcp_mock_server: str):
18+
"""Test fetching tools without account filtering"""
19+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
20+
tools = toolset.fetch_tools()
21+
assert len(tools) == 2
22+
tool_names = [t.name for t in tools.to_list()]
23+
assert "default_tool_1" in tool_names
24+
assert "default_tool_2" in tool_names
25+
26+
def test_fetch_tools_with_account_ids(self, mcp_mock_server: str):
27+
"""Test fetching tools with specific account IDs"""
28+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
29+
tools = toolset.fetch_tools(account_ids=["acc1"])
30+
assert len(tools) == 2
31+
tool_names = [t.name for t in tools.to_list()]
32+
assert "acc1_tool_1" in tool_names
33+
assert "acc1_tool_2" in tool_names
34+
35+
def test_fetch_tools_uses_set_accounts(self, mcp_mock_server: str):
36+
"""Test that fetch_tools uses set_accounts when no accountIds provided"""
37+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
38+
toolset.set_accounts(["acc1", "acc2"])
39+
tools = toolset.fetch_tools()
40+
# acc1 has 2 tools, acc2 has 2 tools, total should be 4
41+
assert len(tools) == 4
42+
tool_names = [t.name for t in tools.to_list()]
43+
assert "acc1_tool_1" in tool_names
44+
assert "acc1_tool_2" in tool_names
45+
assert "acc2_tool_1" in tool_names
46+
assert "acc2_tool_2" in tool_names
47+
48+
def test_fetch_tools_overrides_set_accounts(self, mcp_mock_server: str):
49+
"""Test that accountIds parameter overrides set_accounts"""
50+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
51+
toolset.set_accounts(["acc1", "acc2"])
52+
tools = toolset.fetch_tools(account_ids=["acc3"])
53+
# Should fetch tools only for acc3 (ignoring acc1, acc2)
54+
assert len(tools) == 1
55+
tool_names = [t.name for t in tools.to_list()]
56+
assert "acc3_tool_1" in tool_names
57+
# Verify set_accounts state is preserved
58+
assert toolset._account_ids == ["acc1", "acc2"]
59+
60+
def test_fetch_tools_multiple_account_ids(self, mcp_mock_server: str):
61+
"""Test fetching tools for multiple account IDs"""
62+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
63+
tools = toolset.fetch_tools(account_ids=["acc1", "acc2", "acc3"])
64+
# acc1: 2 tools, acc2: 2 tools, acc3: 1 tool = 5 total
65+
assert len(tools) == 5
66+
67+
def test_fetch_tools_preserves_account_context(self, mcp_mock_server: str):
68+
"""Test that tools preserve their account context"""
69+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
70+
tools = toolset.fetch_tools(account_ids=["acc1"])
71+
72+
tool = tools.get_tool("acc1_tool_1")
73+
assert tool is not None
74+
assert tool.get_account_id() == "acc1"
75+
76+
77+
class TestProviderAndActionFiltering:
78+
"""Test provider and action filtering functionality with real MCP server."""
79+
80+
def test_filter_by_providers(self, mcp_mock_server: str):
81+
"""Test filtering tools by providers"""
82+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
83+
tools = toolset.fetch_tools(account_ids=["mixed"], providers=["hibob", "bamboohr"])
84+
assert len(tools) == 4
85+
tool_names = [t.name for t in tools.to_list()]
86+
assert "hibob_list_employees" in tool_names
87+
assert "hibob_create_employees" in tool_names
88+
assert "bamboohr_list_employees" in tool_names
89+
assert "bamboohr_get_employee" in tool_names
90+
assert "workday_list_employees" not in tool_names
91+
92+
def test_filter_by_actions_exact_match(self, mcp_mock_server: str):
93+
"""Test filtering tools by exact action names"""
94+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
95+
tools = toolset.fetch_tools(
96+
account_ids=["mixed"], actions=["hibob_list_employees", "hibob_create_employees"]
97+
)
98+
assert len(tools) == 2
99+
tool_names = [t.name for t in tools.to_list()]
100+
assert "hibob_list_employees" in tool_names
101+
assert "hibob_create_employees" in tool_names
102+
103+
def test_filter_by_actions_glob_pattern(self, mcp_mock_server: str):
104+
"""Test filtering tools by glob patterns"""
105+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
106+
tools = toolset.fetch_tools(account_ids=["mixed"], actions=["*_list_employees"])
107+
assert len(tools) == 3
108+
tool_names = [t.name for t in tools.to_list()]
109+
assert "hibob_list_employees" in tool_names
110+
assert "bamboohr_list_employees" in tool_names
111+
assert "workday_list_employees" in tool_names
112+
assert "hibob_create_employees" not in tool_names
113+
assert "bamboohr_get_employee" not in tool_names
114+
115+
def test_combine_account_and_action_filters(self, mcp_mock_server: str):
116+
"""Test combining account and action filters"""
117+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
118+
# acc1 has acc1_tool_1, acc1_tool_2
119+
# acc2 has acc2_tool_1, acc2_tool_2
120+
tools = toolset.fetch_tools(account_ids=["acc1", "acc2"], actions=["*_tool_1"])
121+
assert len(tools) == 2
122+
tool_names = [t.name for t in tools.to_list()]
123+
assert "acc1_tool_1" in tool_names
124+
assert "acc2_tool_1" in tool_names
125+
assert "acc1_tool_2" not in tool_names
126+
assert "acc2_tool_2" not in tool_names
127+
128+
def test_combine_provider_and_action_filters(self, mcp_mock_server: str):
129+
"""Test combining providers and actions filters"""
130+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
131+
tools = toolset.fetch_tools(account_ids=["mixed"], providers=["hibob"], actions=["*_list_*"])
132+
# Should only return hibob_list_employees (matches both filters)
133+
assert len(tools) == 1
134+
tool_names = [t.name for t in tools.to_list()]
135+
assert "hibob_list_employees" in tool_names
136+
137+
138+
class TestMcpHeaders:
139+
"""Test that MCP headers are built correctly."""
140+
141+
def test_authorization_header_is_set(self, mcp_mock_server: str):
142+
"""Test that authorization header is properly set (server validates basic auth)"""
143+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
144+
# If auth fails, this would raise an error
145+
tools = toolset.fetch_tools()
146+
assert len(tools) > 0
147+
148+
def test_account_id_header_is_sent(self, mcp_mock_server: str):
149+
"""Test that x-account-id header is sent when account_id is provided"""
150+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
151+
# When we fetch with acc1, we should get acc1's tools, proving header was sent
152+
tools = toolset.fetch_tools(account_ids=["acc1"])
153+
tool_names = [t.name for t in tools.to_list()]
154+
assert all("acc1" in name for name in tool_names)
155+
156+
157+
class TestToolCreation:
158+
"""Test that tools are created correctly from MCP responses."""
159+
160+
def test_tool_has_name_and_description(self, mcp_mock_server: str):
161+
"""Test that tools have proper name and description"""
162+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
163+
tools = toolset.fetch_tools()
164+
tool = tools.get_tool("default_tool_1")
165+
assert tool is not None
166+
assert tool.name == "default_tool_1"
167+
assert tool.description == "Default Tool 1"
168+
169+
def test_tool_has_parameters_type(self, mcp_mock_server: str):
170+
"""Test that tools have proper parameters type from input schema"""
171+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
172+
tools = toolset.fetch_tools()
173+
tool = tools.get_tool("default_tool_1")
174+
assert tool is not None
175+
assert tool.parameters is not None
176+
assert tool.parameters.type == "object"
177+
178+
179+
class TestSchemaPropertyNormalization:
180+
"""Test schema property normalization with monkeypatch (for precise schema control)."""
181+
182+
def test_tool_properties_are_normalized(self, monkeypatch):
183+
"""Test that tool properties are correctly extracted from input schema"""
184+
from stackone_ai.toolset import _McpToolDefinition
185+
186+
def fake_fetch(_: str, headers: dict[str, str]) -> list[_McpToolDefinition]:
187+
return [
188+
_McpToolDefinition(
189+
name="test_tool",
190+
description="Test tool",
191+
input_schema={
192+
"type": "object",
193+
"properties": {
194+
"name": {"type": "string", "description": "The name"},
195+
"age": {"type": "integer"},
196+
},
197+
"required": ["name"],
198+
},
199+
)
200+
]
201+
202+
monkeypatch.setattr("stackone_ai.toolset._fetch_mcp_tools", fake_fetch)
203+
204+
toolset = StackOneToolSet(api_key="test-key")
205+
tools = toolset.fetch_tools()
206+
tool = tools.get_tool("test_tool")
207+
assert tool is not None
208+
assert "name" in tool.parameters.properties
209+
assert "age" in tool.parameters.properties
210+
211+
def test_required_fields_marked_not_nullable(self, monkeypatch):
212+
"""Test that required fields are marked as not nullable"""
213+
from stackone_ai.toolset import _McpToolDefinition
214+
215+
def fake_fetch(_: str, headers: dict[str, str]) -> list[_McpToolDefinition]:
216+
return [
217+
_McpToolDefinition(
218+
name="test_tool",
219+
description="Test tool",
220+
input_schema={
221+
"type": "object",
222+
"properties": {"id": {"type": "string"}},
223+
"required": ["id"],
224+
},
225+
)
226+
]
227+
228+
monkeypatch.setattr("stackone_ai.toolset._fetch_mcp_tools", fake_fetch)
229+
230+
toolset = StackOneToolSet(api_key="test-key")
231+
tools = toolset.fetch_tools()
232+
tool = tools.get_tool("test_tool")
233+
assert tool is not None
234+
assert tool.parameters.properties["id"].get("nullable") is False
235+
236+
def test_optional_fields_marked_nullable(self, monkeypatch):
237+
"""Test that optional fields are marked as nullable"""
238+
from stackone_ai.toolset import _McpToolDefinition
239+
240+
def fake_fetch(_: str, headers: dict[str, str]) -> list[_McpToolDefinition]:
241+
return [
242+
_McpToolDefinition(
243+
name="test_tool",
244+
description="Test tool",
245+
input_schema={
246+
"type": "object",
247+
"properties": {"optional_field": {"type": "string"}},
248+
},
249+
)
250+
]
251+
252+
monkeypatch.setattr("stackone_ai.toolset._fetch_mcp_tools", fake_fetch)
253+
254+
toolset = StackOneToolSet(api_key="test-key")
255+
tools = toolset.fetch_tools()
256+
tool = tools.get_tool("test_tool")
257+
assert tool is not None
258+
assert tool.parameters.properties["optional_field"].get("nullable") is True
259+
260+
261+
class TestRpcToolExecution:
262+
"""Test RPC tool execution through the MCP server."""
263+
264+
def test_execute_tool_returns_response(self, mcp_mock_server: str):
265+
"""Test executing a tool via RPC returns response"""
266+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
267+
tools = toolset.fetch_tools(account_ids=["your-bamboohr-account-id"])
268+
tool = tools.get_tool("bamboohr_list_employees")
269+
assert tool is not None
270+
271+
result = tool.execute()
272+
assert result is not None
273+
assert "data" in result
274+
275+
def test_execute_tool_with_arguments(self, mcp_mock_server: str):
276+
"""Test executing a tool with arguments"""
277+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
278+
tools = toolset.fetch_tools(account_ids=["your-bamboohr-account-id"])
279+
tool = tools.get_tool("bamboohr_get_employee")
280+
assert tool is not None
281+
282+
result = tool.execute({"id": "emp-123"})
283+
assert result is not None
284+
assert result.get("data", {}).get("id") == "emp-123"
285+
286+
def test_execute_tool_sends_account_id_header(self, mcp_mock_server: str):
287+
"""Test that tool execution sends x-account-id header"""
288+
toolset = StackOneToolSet(api_key="test-key", base_url=mcp_mock_server)
289+
tools = toolset.fetch_tools(account_ids=["test-account"])
290+
tool = tools.get_tool("dummy_action")
291+
assert tool is not None
292+
assert tool.get_account_id() == "test-account"
293+
294+
# Execute and verify account context is preserved
295+
result = tool.execute({"foo": "bar"})
296+
assert result is not None

0 commit comments

Comments
 (0)