/ tests / test_mcp_server.py
test_mcp_server.py
  1  """MCP サーバーテスト
  2  
  3  MCPサーバーの list_tools / call_tool の動作を検証する。
  4  GoogleAdsApiClient / MetaAdsApiClient はモックし、stdio 層は介さずサーバー関数を直接呼ぶ。
  5  """
  6  
  7  from __future__ import annotations
  8  
  9  import json
 10  from typing import Any
 11  from unittest.mock import AsyncMock, MagicMock, patch
 12  
 13  import pytest
 14  
 15  
 16  # ---------------------------------------------------------------------------
 17  # ヘルパー
 18  # ---------------------------------------------------------------------------
 19  
 20  
 21  def _import_server_module():
 22      """mureo/mcp/server.py をインポートする"""
 23      from mureo.mcp import server as mcp_server_module
 24  
 25      return mcp_server_module
 26  
 27  
 28  def _import_google_ads_handlers():
 29      from mureo.mcp import _handlers_google_ads
 30  
 31      return _handlers_google_ads
 32  
 33  
 34  # ---------------------------------------------------------------------------
 35  # list_tools テスト
 36  # ---------------------------------------------------------------------------
 37  
 38  
 39  @pytest.mark.unit
 40  class TestListTools:
 41      """list_tools が正しいツール定義を返すことを検証する"""
 42  
 43      async def test_list_tools_returns_all_tools(self) -> None:
 44          """list_tools は全ツール(Google Ads 83 + Meta Ads 77 + Search Console 10
 45          + Rollback 2 + Analysis 1 = 173)を返す"""
 46          mod = _import_server_module()
 47          tools = await mod.handle_list_tools()
 48          assert len(tools) == 173
 49  
 50      async def test_list_tools_contains_google_and_meta(self) -> None:
 51          """Google Ads と Meta Ads のツールが含まれること"""
 52          mod = _import_server_module()
 53          tools = await mod.handle_list_tools()
 54          names = {t.name for t in tools}
 55          assert "google_ads.campaigns.list" in names
 56          assert "google_ads.campaigns.get" in names
 57          assert "meta_ads.campaigns.list" in names
 58          assert "meta_ads.campaigns.get" in names
 59  
 60      async def test_list_tools_campaigns_list_schema(self) -> None:
 61          """campaigns.list の inputSchema が customer_id を optional で持つこと"""
 62          mod = _import_server_module()
 63          tools = await mod.handle_list_tools()
 64          tool = next(t for t in tools if t.name == "google_ads.campaigns.list")
 65          schema = tool.inputSchema
 66          assert schema["type"] == "object"
 67          assert "customer_id" in schema["properties"]
 68          # customer_id is optional (falls back to credentials.json)
 69          assert "customer_id" not in schema.get("required", [])
 70  
 71      async def test_list_tools_campaigns_get_schema(self) -> None:
 72          """campaigns.get の inputSchema が campaign_id を required で持つこと"""
 73          mod = _import_server_module()
 74          tools = await mod.handle_list_tools()
 75          tool = next(t for t in tools if t.name == "google_ads.campaigns.get")
 76          schema = tool.inputSchema
 77          assert schema["type"] == "object"
 78          assert "customer_id" in schema["properties"]
 79          assert "campaign_id" in schema["properties"]
 80          # customer_id is optional, campaign_id is required
 81          assert "campaign_id" in schema["required"]
 82  
 83  
 84  # ---------------------------------------------------------------------------
 85  # call_tool テスト
 86  # ---------------------------------------------------------------------------
 87  
 88  
 89  @pytest.mark.unit
 90  class TestCallToolCampaignsList:
 91      """call_tool で google_ads.campaigns.list を呼ぶテスト"""
 92  
 93      async def test_campaigns_list_calls_client(self) -> None:
 94          """campaigns.list が GoogleAdsApiClient.list_campaigns を呼ぶこと"""
 95          mod = _import_server_module()
 96          ga_mod = _import_google_ads_handlers()
 97  
 98          mock_client = AsyncMock()
 99          mock_client.list_campaigns.return_value = [
100              {"id": "123", "name": "Campaign 1", "status": "ENABLED"}
101          ]
102  
103          mock_creds = MagicMock()
104          with (
105              patch.object(
106                  ga_mod, "load_google_ads_credentials", return_value=mock_creds
107              ),
108              patch.object(ga_mod, "create_google_ads_client", return_value=mock_client),
109          ):
110              result = await mod.handle_call_tool(
111                  "google_ads.campaigns.list",
112                  {"customer_id": "1234567890"},
113              )
114  
115          mock_client.list_campaigns.assert_awaited_once()
116          assert len(result) == 1
117          assert result[0].type == "text"
118          parsed = json.loads(result[0].text)
119          assert len(parsed) == 1
120          assert parsed[0]["id"] == "123"
121  
122  
123  @pytest.mark.unit
124  class TestCallToolCampaignsGet:
125      """call_tool で google_ads.campaigns.get を呼ぶテスト"""
126  
127      async def test_campaigns_get_calls_client(self) -> None:
128          """campaigns.get が GoogleAdsApiClient.get_campaign を呼ぶこと"""
129          mod = _import_server_module()
130          ga_mod = _import_google_ads_handlers()
131  
132          mock_client = AsyncMock()
133          mock_client.get_campaign.return_value = {
134              "id": "456",
135              "name": "Campaign Detail",
136              "status": "PAUSED",
137              "budget_daily": 5000.0,
138          }
139  
140          mock_creds = MagicMock()
141          with (
142              patch.object(
143                  ga_mod, "load_google_ads_credentials", return_value=mock_creds
144              ),
145              patch.object(ga_mod, "create_google_ads_client", return_value=mock_client),
146          ):
147              result = await mod.handle_call_tool(
148                  "google_ads.campaigns.get",
149                  {"customer_id": "1234567890", "campaign_id": "456"},
150              )
151  
152          mock_client.get_campaign.assert_awaited_once_with("456")
153          assert len(result) == 1
154          parsed = json.loads(result[0].text)
155          assert parsed["id"] == "456"
156          assert parsed["budget_daily"] == 5000.0
157  
158  
159  @pytest.mark.unit
160  class TestCallToolErrors:
161      """call_tool のエラーケースを検証する"""
162  
163      async def test_unknown_tool_raises_error(self) -> None:
164          """未知のツール名で ValueError が発生すること"""
165          mod = _import_server_module()
166  
167          with pytest.raises(ValueError, match="Unknown tool"):
168              await mod.handle_call_tool("nonexistent.tool", {})
169  
170      async def test_missing_required_param_customer_id(self) -> None:
171          """campaigns.list で customer_id が欠損 + credentials.jsonにもない場合"""
172          mod = _import_server_module()
173  
174          with patch(
175              "mureo.mcp._handlers_google_ads.load_google_ads_credentials",
176              return_value=None,
177          ):
178              result = await mod.handle_call_tool("google_ads.campaigns.list", {})
179              assert any("Credentials not found" in r.text for r in result)
180  
181      async def test_missing_required_param_campaign_id(self) -> None:
182          """campaigns.get で campaign_id が欠損した場合にエラーになること"""
183          mod = _import_server_module()
184          ga_mod = _import_google_ads_handlers()
185  
186          mock_creds = MagicMock()
187          with patch.object(
188              ga_mod, "load_google_ads_credentials", return_value=mock_creds
189          ):
190              with pytest.raises(ValueError, match="campaign_id"):
191                  await mod.handle_call_tool(
192                      "google_ads.campaigns.get",
193                      {"customer_id": "1234567890"},
194                  )
195  
196      async def test_no_credentials_returns_error_text(self) -> None:
197          """認証情報がない場合、エラーメッセージを TextContent で返すこと"""
198          ga_mod = _import_google_ads_handlers()
199          mod = _import_server_module()
200  
201          with patch.object(
202              ga_mod,
203              "load_google_ads_credentials",
204              return_value=None,
205          ):
206              result = await mod.handle_call_tool(
207                  "google_ads.campaigns.list",
208                  {"customer_id": "1234567890"},
209              )
210  
211          assert len(result) == 1
212          assert result[0].type == "text"
213          assert "Credentials not found" in result[0].text