amazon-ads-mcp 0.2.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- amazon_ads_mcp/__init__.py +11 -0
- amazon_ads_mcp/auth/__init__.py +33 -0
- amazon_ads_mcp/auth/base.py +211 -0
- amazon_ads_mcp/auth/hooks.py +172 -0
- amazon_ads_mcp/auth/manager.py +791 -0
- amazon_ads_mcp/auth/oauth_state_store.py +277 -0
- amazon_ads_mcp/auth/providers/__init__.py +14 -0
- amazon_ads_mcp/auth/providers/direct.py +393 -0
- amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
- amazon_ads_mcp/auth/providers/openbridge.py +512 -0
- amazon_ads_mcp/auth/registry.py +146 -0
- amazon_ads_mcp/auth/secure_token_store.py +297 -0
- amazon_ads_mcp/auth/token_store.py +723 -0
- amazon_ads_mcp/config/__init__.py +5 -0
- amazon_ads_mcp/config/sampling.py +111 -0
- amazon_ads_mcp/config/settings.py +366 -0
- amazon_ads_mcp/exceptions.py +314 -0
- amazon_ads_mcp/middleware/__init__.py +11 -0
- amazon_ads_mcp/middleware/authentication.py +1474 -0
- amazon_ads_mcp/middleware/caching.py +177 -0
- amazon_ads_mcp/middleware/oauth.py +175 -0
- amazon_ads_mcp/middleware/sampling.py +112 -0
- amazon_ads_mcp/models/__init__.py +320 -0
- amazon_ads_mcp/models/amc_models.py +837 -0
- amazon_ads_mcp/models/api_responses.py +847 -0
- amazon_ads_mcp/models/base_models.py +215 -0
- amazon_ads_mcp/models/builtin_responses.py +496 -0
- amazon_ads_mcp/models/dsp_models.py +556 -0
- amazon_ads_mcp/models/stores_brands.py +610 -0
- amazon_ads_mcp/server/__init__.py +6 -0
- amazon_ads_mcp/server/__main__.py +6 -0
- amazon_ads_mcp/server/builtin_prompts.py +269 -0
- amazon_ads_mcp/server/builtin_tools.py +962 -0
- amazon_ads_mcp/server/file_routes.py +547 -0
- amazon_ads_mcp/server/html_templates.py +149 -0
- amazon_ads_mcp/server/mcp_server.py +327 -0
- amazon_ads_mcp/server/openapi_utils.py +158 -0
- amazon_ads_mcp/server/sampling_handler.py +251 -0
- amazon_ads_mcp/server/server_builder.py +751 -0
- amazon_ads_mcp/server/sidecar_loader.py +178 -0
- amazon_ads_mcp/server/transform_executor.py +827 -0
- amazon_ads_mcp/tools/__init__.py +22 -0
- amazon_ads_mcp/tools/cache_management.py +105 -0
- amazon_ads_mcp/tools/download_tools.py +267 -0
- amazon_ads_mcp/tools/identity.py +236 -0
- amazon_ads_mcp/tools/oauth.py +598 -0
- amazon_ads_mcp/tools/profile.py +150 -0
- amazon_ads_mcp/tools/profile_listing.py +285 -0
- amazon_ads_mcp/tools/region.py +320 -0
- amazon_ads_mcp/tools/region_identity.py +175 -0
- amazon_ads_mcp/utils/__init__.py +6 -0
- amazon_ads_mcp/utils/async_compat.py +215 -0
- amazon_ads_mcp/utils/errors.py +452 -0
- amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
- amazon_ads_mcp/utils/export_download_handler.py +579 -0
- amazon_ads_mcp/utils/header_resolver.py +81 -0
- amazon_ads_mcp/utils/http/__init__.py +56 -0
- amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
- amazon_ads_mcp/utils/http/client_manager.py +329 -0
- amazon_ads_mcp/utils/http/request.py +207 -0
- amazon_ads_mcp/utils/http/resilience.py +512 -0
- amazon_ads_mcp/utils/http/resilient_client.py +195 -0
- amazon_ads_mcp/utils/http/retry.py +76 -0
- amazon_ads_mcp/utils/http_client.py +873 -0
- amazon_ads_mcp/utils/media/__init__.py +21 -0
- amazon_ads_mcp/utils/media/negotiator.py +243 -0
- amazon_ads_mcp/utils/media/types.py +199 -0
- amazon_ads_mcp/utils/openapi/__init__.py +16 -0
- amazon_ads_mcp/utils/openapi/json.py +55 -0
- amazon_ads_mcp/utils/openapi/loader.py +263 -0
- amazon_ads_mcp/utils/openapi/refs.py +46 -0
- amazon_ads_mcp/utils/region_config.py +200 -0
- amazon_ads_mcp/utils/response_wrapper.py +171 -0
- amazon_ads_mcp/utils/sampling_helpers.py +156 -0
- amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
- amazon_ads_mcp/utils/security.py +630 -0
- amazon_ads_mcp/utils/tool_naming.py +137 -0
- amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
- amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
- amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
- amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
- amazon_ads_mcp-0.2.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
"""Register built-in tools for the MCP server.
|
|
2
|
+
|
|
3
|
+
Handle registration of identity, profile, region, download, sampling, and
|
|
4
|
+
authentication tools depending on the active provider.
|
|
5
|
+
|
|
6
|
+
Examples
|
|
7
|
+
--------
|
|
8
|
+
.. code-block:: python
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
from amazon_ads_mcp.server.builtin_tools import register_all_builtin_tools
|
|
13
|
+
|
|
14
|
+
async def main():
|
|
15
|
+
server = FastMCP("amazon-ads")
|
|
16
|
+
await register_all_builtin_tools(server)
|
|
17
|
+
|
|
18
|
+
asyncio.run(main())
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from fastmcp import Context, FastMCP
|
|
25
|
+
|
|
26
|
+
from fastmcp.dependencies import Progress
|
|
27
|
+
|
|
28
|
+
from ..auth.manager import get_auth_manager
|
|
29
|
+
from ..config.settings import settings
|
|
30
|
+
from ..models.builtin_responses import (
|
|
31
|
+
AsyncReportResponse,
|
|
32
|
+
ClearProfileResponse,
|
|
33
|
+
DownloadedFile,
|
|
34
|
+
DownloadExportResponse,
|
|
35
|
+
GetDownloadUrlResponse,
|
|
36
|
+
GetProfileResponse,
|
|
37
|
+
GetRegionResponse,
|
|
38
|
+
ListDownloadsResponse,
|
|
39
|
+
ListRegionsResponse,
|
|
40
|
+
ProfileSelectorResponse,
|
|
41
|
+
ProfilePageResponse,
|
|
42
|
+
ProfileCacheRefreshResponse,
|
|
43
|
+
ProfileSearchResponse,
|
|
44
|
+
ProfileSummaryResponse,
|
|
45
|
+
RoutingStateResponse,
|
|
46
|
+
SamplingTestResponse,
|
|
47
|
+
SetProfileResponse,
|
|
48
|
+
SetRegionResponse,
|
|
49
|
+
)
|
|
50
|
+
from ..tools import identity, profile, profile_listing, region
|
|
51
|
+
from ..tools.oauth import OAuthTools
|
|
52
|
+
|
|
53
|
+
# Removed http_client imports - override functions were removed
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def register_identity_tools(server: FastMCP):
|
|
59
|
+
"""Register identity management tools.
|
|
60
|
+
|
|
61
|
+
:param server: FastMCP server instance.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
@server.tool(
|
|
65
|
+
name="set_active_identity",
|
|
66
|
+
description="Set the active identity for Amazon Ads API calls",
|
|
67
|
+
)
|
|
68
|
+
async def set_active_identity_tool(
|
|
69
|
+
ctx: Context,
|
|
70
|
+
identity_id: str,
|
|
71
|
+
persist: bool = False,
|
|
72
|
+
):
|
|
73
|
+
"""Set the active identity for API calls."""
|
|
74
|
+
from ..models import SetActiveIdentityRequest
|
|
75
|
+
|
|
76
|
+
req = SetActiveIdentityRequest(
|
|
77
|
+
identity_id=identity_id,
|
|
78
|
+
persist=persist,
|
|
79
|
+
)
|
|
80
|
+
return await identity.set_active_identity(req)
|
|
81
|
+
|
|
82
|
+
@server.tool(
|
|
83
|
+
name="get_active_identity",
|
|
84
|
+
description="Get the currently active identity",
|
|
85
|
+
)
|
|
86
|
+
async def get_active_identity_tool(ctx: Context):
|
|
87
|
+
"""Get the currently active identity."""
|
|
88
|
+
return await identity.get_active_identity()
|
|
89
|
+
|
|
90
|
+
@server.tool(name="list_identities", description="List all available identities")
|
|
91
|
+
async def list_identities_tool(ctx: Context) -> dict:
|
|
92
|
+
"""List all available identities."""
|
|
93
|
+
return await identity.list_identities()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def register_profile_tools(server: FastMCP):
|
|
97
|
+
"""Register profile management tools.
|
|
98
|
+
|
|
99
|
+
:param server: FastMCP server instance.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
@server.tool(
|
|
103
|
+
name="set_active_profile",
|
|
104
|
+
description="Set the active profile ID for API calls",
|
|
105
|
+
)
|
|
106
|
+
async def set_active_profile_tool(
|
|
107
|
+
ctx: Context, profile_id: str
|
|
108
|
+
) -> SetProfileResponse:
|
|
109
|
+
"""Set the active profile ID."""
|
|
110
|
+
result = await profile.set_active_profile(profile_id)
|
|
111
|
+
return SetProfileResponse(**result)
|
|
112
|
+
|
|
113
|
+
@server.tool(
|
|
114
|
+
name="get_active_profile",
|
|
115
|
+
description="Get the currently active profile ID",
|
|
116
|
+
)
|
|
117
|
+
async def get_active_profile_tool(ctx: Context) -> GetProfileResponse:
|
|
118
|
+
"""Get the currently active profile ID."""
|
|
119
|
+
result = await profile.get_active_profile()
|
|
120
|
+
return GetProfileResponse(**result)
|
|
121
|
+
|
|
122
|
+
@server.tool(name="clear_active_profile", description="Clear the active profile ID")
|
|
123
|
+
async def clear_active_profile_tool(ctx: Context) -> ClearProfileResponse:
|
|
124
|
+
"""Clear the active profile ID."""
|
|
125
|
+
result = await profile.clear_active_profile()
|
|
126
|
+
return ClearProfileResponse(**result)
|
|
127
|
+
|
|
128
|
+
@server.tool(
|
|
129
|
+
name="select_profile",
|
|
130
|
+
description="Interactively select a profile from available options",
|
|
131
|
+
)
|
|
132
|
+
async def select_profile_tool(ctx: Context) -> ProfileSelectorResponse:
|
|
133
|
+
"""Interactively select an Amazon Ads profile.
|
|
134
|
+
|
|
135
|
+
This tool uses MCP elicitation to present available profiles to the user
|
|
136
|
+
and let them select one interactively. This is more user-friendly than
|
|
137
|
+
requiring users to call list_profiles and set_active_profile separately.
|
|
138
|
+
|
|
139
|
+
The tool will:
|
|
140
|
+
1. Fetch available profiles from the Amazon Ads API
|
|
141
|
+
2. Present them to the user via elicitation
|
|
142
|
+
3. Set the selected profile as active
|
|
143
|
+
4. Return the selection result
|
|
144
|
+
"""
|
|
145
|
+
from dataclasses import dataclass
|
|
146
|
+
|
|
147
|
+
from ..tools import profile_listing
|
|
148
|
+
|
|
149
|
+
# Define the selection structure for elicitation
|
|
150
|
+
@dataclass
|
|
151
|
+
class ProfileSelection:
|
|
152
|
+
profile_id: str
|
|
153
|
+
|
|
154
|
+
# Fetch available profiles
|
|
155
|
+
try:
|
|
156
|
+
profiles_data, stale = await profile_listing.get_profiles_cached()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Failed to fetch profiles: {e}")
|
|
159
|
+
return ProfileSelectorResponse(
|
|
160
|
+
success=False,
|
|
161
|
+
action="cancel",
|
|
162
|
+
message=f"Failed to fetch profiles: {e}",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if not profiles_data:
|
|
166
|
+
return ProfileSelectorResponse(
|
|
167
|
+
success=False,
|
|
168
|
+
action="cancel",
|
|
169
|
+
message="No profiles available. Please ensure you have access to advertising accounts.",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if len(profiles_data) > profile_listing.PROFILE_SELECTION_THRESHOLD:
|
|
173
|
+
message = (
|
|
174
|
+
"Too many profiles to display here. Use summarize_profiles, "
|
|
175
|
+
"search_profiles, or page_profiles to locate the right profile."
|
|
176
|
+
)
|
|
177
|
+
if stale:
|
|
178
|
+
message = "Using cached profile list; data may be stale. " + message
|
|
179
|
+
return ProfileSelectorResponse(
|
|
180
|
+
success=True,
|
|
181
|
+
action="cancel",
|
|
182
|
+
message=message,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Build a formatted message with profile options
|
|
186
|
+
profile_list = []
|
|
187
|
+
for p in profiles_data:
|
|
188
|
+
profile_id = str(p.get("profileId", ""))
|
|
189
|
+
country = p.get("countryCode", "")
|
|
190
|
+
account_info = p.get("accountInfo", {})
|
|
191
|
+
account_name = account_info.get("name", "Unknown")
|
|
192
|
+
account_type = account_info.get("type", "Unknown")
|
|
193
|
+
profile_list.append(
|
|
194
|
+
f" - {profile_id}: {account_name} ({country}, {account_type})"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
profiles_message = (
|
|
198
|
+
f"Available profiles ({len(profiles_data)} found):\n"
|
|
199
|
+
+ "\n".join(profile_list)
|
|
200
|
+
+ "\n\nEnter the profile ID you want to use:"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Use elicitation to let user select
|
|
204
|
+
try:
|
|
205
|
+
result = await ctx.elicit(
|
|
206
|
+
message=profiles_message,
|
|
207
|
+
response_type=ProfileSelection,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if result.action == "accept":
|
|
211
|
+
selected_id = result.data.profile_id
|
|
212
|
+
|
|
213
|
+
# Validate the selection
|
|
214
|
+
valid_ids = [str(p.get("profileId", "")) for p in profiles_data]
|
|
215
|
+
if selected_id not in valid_ids:
|
|
216
|
+
return ProfileSelectorResponse(
|
|
217
|
+
success=False,
|
|
218
|
+
action="accept",
|
|
219
|
+
message=f"Invalid profile ID: {selected_id}. Please select from the available profiles.",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Set the selected profile as active
|
|
223
|
+
await profile.set_active_profile(selected_id)
|
|
224
|
+
|
|
225
|
+
# Find the profile name for the response
|
|
226
|
+
selected_profile = next(
|
|
227
|
+
(p for p in profiles_data if str(p.get("profileId")) == selected_id),
|
|
228
|
+
None,
|
|
229
|
+
)
|
|
230
|
+
profile_name = (
|
|
231
|
+
selected_profile.get("accountInfo", {}).get("name", "Unknown")
|
|
232
|
+
if selected_profile
|
|
233
|
+
else "Unknown"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return ProfileSelectorResponse(
|
|
237
|
+
success=True,
|
|
238
|
+
action="accept",
|
|
239
|
+
profile_id=selected_id,
|
|
240
|
+
profile_name=profile_name,
|
|
241
|
+
message=f"Profile '{profile_name}' ({selected_id}) is now active.",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
elif result.action == "decline":
|
|
245
|
+
return ProfileSelectorResponse(
|
|
246
|
+
success=True,
|
|
247
|
+
action="decline",
|
|
248
|
+
message="Profile selection declined. No changes made.",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
else: # cancel
|
|
252
|
+
return ProfileSelectorResponse(
|
|
253
|
+
success=True,
|
|
254
|
+
action="cancel",
|
|
255
|
+
message="Profile selection cancelled.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.error(f"Elicitation failed: {e}")
|
|
260
|
+
return ProfileSelectorResponse(
|
|
261
|
+
success=False,
|
|
262
|
+
action="cancel",
|
|
263
|
+
message=f"Profile selection failed: {e}",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def register_profile_listing_tools(server: FastMCP):
|
|
268
|
+
"""Register profile listing tools with bounded responses."""
|
|
269
|
+
|
|
270
|
+
@server.tool(
|
|
271
|
+
name="summarize_profiles",
|
|
272
|
+
description="Summarize available profiles by country and account type",
|
|
273
|
+
)
|
|
274
|
+
async def summarize_profiles_tool(ctx: Context) -> ProfileSummaryResponse:
|
|
275
|
+
"""Summarize available profiles."""
|
|
276
|
+
result = await profile_listing.summarize_profiles()
|
|
277
|
+
return ProfileSummaryResponse(**result)
|
|
278
|
+
|
|
279
|
+
@server.tool(
|
|
280
|
+
name="search_profiles",
|
|
281
|
+
description="Search profiles by name, country, or account type",
|
|
282
|
+
)
|
|
283
|
+
async def search_profiles_tool(
|
|
284
|
+
ctx: Context,
|
|
285
|
+
query: Optional[str] = None,
|
|
286
|
+
country_code: Optional[str] = None,
|
|
287
|
+
account_type: Optional[str] = None,
|
|
288
|
+
limit: int = profile_listing.DEFAULT_SEARCH_LIMIT,
|
|
289
|
+
) -> ProfileSearchResponse:
|
|
290
|
+
"""Search profiles with bounded output."""
|
|
291
|
+
result = await profile_listing.search_profiles(
|
|
292
|
+
query=query,
|
|
293
|
+
country_code=country_code,
|
|
294
|
+
account_type=account_type,
|
|
295
|
+
limit=limit,
|
|
296
|
+
)
|
|
297
|
+
return ProfileSearchResponse(**result)
|
|
298
|
+
|
|
299
|
+
@server.tool(
|
|
300
|
+
name="page_profiles",
|
|
301
|
+
description="Page through profiles with offset and limit",
|
|
302
|
+
)
|
|
303
|
+
async def page_profiles_tool(
|
|
304
|
+
ctx: Context,
|
|
305
|
+
country_code: Optional[str] = None,
|
|
306
|
+
account_type: Optional[str] = None,
|
|
307
|
+
offset: int = 0,
|
|
308
|
+
limit: int = profile_listing.DEFAULT_PAGE_LIMIT,
|
|
309
|
+
) -> ProfilePageResponse:
|
|
310
|
+
"""Return a page of profiles with bounded output."""
|
|
311
|
+
result = await profile_listing.page_profiles(
|
|
312
|
+
country_code=country_code,
|
|
313
|
+
account_type=account_type,
|
|
314
|
+
offset=offset,
|
|
315
|
+
limit=limit,
|
|
316
|
+
)
|
|
317
|
+
return ProfilePageResponse(**result)
|
|
318
|
+
|
|
319
|
+
@server.tool(
|
|
320
|
+
name="refresh_profiles_cache",
|
|
321
|
+
description="Force refresh of cached profiles for the current identity and region",
|
|
322
|
+
)
|
|
323
|
+
async def refresh_profiles_cache_tool(ctx: Context) -> ProfileCacheRefreshResponse:
|
|
324
|
+
"""Force refresh the cached profile list."""
|
|
325
|
+
result = await profile_listing.refresh_profiles_cache()
|
|
326
|
+
return ProfileCacheRefreshResponse(**result)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def register_region_tools(server: FastMCP):
|
|
330
|
+
"""Register region management tools.
|
|
331
|
+
|
|
332
|
+
:param server: FastMCP server instance.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
@server.tool(
|
|
336
|
+
name="set_region",
|
|
337
|
+
description="Set the region for Amazon Ads API calls",
|
|
338
|
+
)
|
|
339
|
+
async def set_region_tool(ctx: Context, region_code: str) -> SetRegionResponse:
|
|
340
|
+
"""Set the region for API calls."""
|
|
341
|
+
result = await region.set_region(region_code)
|
|
342
|
+
return SetRegionResponse(**result)
|
|
343
|
+
|
|
344
|
+
@server.tool(name="get_region", description="Get the current region setting")
|
|
345
|
+
async def get_region_tool(ctx: Context) -> GetRegionResponse:
|
|
346
|
+
"""Get the current region."""
|
|
347
|
+
result = await region.get_region()
|
|
348
|
+
return GetRegionResponse(**result)
|
|
349
|
+
|
|
350
|
+
@server.tool(name="list_regions", description="List all available regions")
|
|
351
|
+
async def list_regions_tool(ctx: Context) -> ListRegionsResponse:
|
|
352
|
+
"""List available regions."""
|
|
353
|
+
result = await region.list_regions()
|
|
354
|
+
return ListRegionsResponse(**result)
|
|
355
|
+
|
|
356
|
+
@server.tool(
|
|
357
|
+
name="get_routing_state",
|
|
358
|
+
description="Get the current routing state including region, host, and headers",
|
|
359
|
+
)
|
|
360
|
+
async def get_routing_state_tool(ctx: Context) -> RoutingStateResponse:
|
|
361
|
+
"""Get the complete routing state for debugging."""
|
|
362
|
+
from ..utils.http_client import get_routing_state
|
|
363
|
+
from ..utils.region_config import RegionConfig
|
|
364
|
+
|
|
365
|
+
result = get_routing_state()
|
|
366
|
+
|
|
367
|
+
# Provide defaults when no routing state has been established yet
|
|
368
|
+
# (e.g., on a fresh server before any requests)
|
|
369
|
+
current_region = settings.amazon_ads_region or "na"
|
|
370
|
+
default_host = RegionConfig.get_api_host(current_region)
|
|
371
|
+
|
|
372
|
+
# Apply sandbox host replacement (same pattern used in http_client.py)
|
|
373
|
+
if settings.amazon_ads_sandbox_mode:
|
|
374
|
+
default_host = default_host.replace("advertising-api", "advertising-api-test")
|
|
375
|
+
|
|
376
|
+
return RoutingStateResponse(
|
|
377
|
+
region=result.get("region", current_region),
|
|
378
|
+
host=result.get("host", default_host),
|
|
379
|
+
headers=result.get("headers", {}),
|
|
380
|
+
sandbox=settings.amazon_ads_sandbox_mode,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Removed region_identity_tools - list_identities_by_region was just a convenience wrapper
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Routing override tools removed - use the main region/marketplace tools instead
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def register_download_tools(server: FastMCP):
|
|
391
|
+
"""Register download management tools.
|
|
392
|
+
|
|
393
|
+
:param server: FastMCP server instance
|
|
394
|
+
:type server: FastMCP
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
# Background task with progress reporting for long-running downloads
|
|
398
|
+
# task=True enables MCP background task protocol (SEP-1686) in FastMCP 2.14+
|
|
399
|
+
@server.tool(
|
|
400
|
+
name="download_export",
|
|
401
|
+
description="Download a completed export to local storage (supports background execution)",
|
|
402
|
+
task=True, # Enable background task execution
|
|
403
|
+
)
|
|
404
|
+
async def download_export_tool(
|
|
405
|
+
ctx: Context,
|
|
406
|
+
export_id: str,
|
|
407
|
+
export_url: str,
|
|
408
|
+
progress: Progress = Progress(), # Inject progress tracker
|
|
409
|
+
) -> DownloadExportResponse:
|
|
410
|
+
"""Download a completed export to local storage.
|
|
411
|
+
|
|
412
|
+
This tool supports background execution with progress reporting.
|
|
413
|
+
When called with task=True by the client, it returns immediately
|
|
414
|
+
with a task ID while the download continues in the background.
|
|
415
|
+
"""
|
|
416
|
+
import base64
|
|
417
|
+
|
|
418
|
+
from ..utils.export_download_handler import get_download_handler
|
|
419
|
+
|
|
420
|
+
# Report progress: starting download
|
|
421
|
+
await progress.set_total(3) # 3 steps: parse, download, complete
|
|
422
|
+
await progress.set_message("Parsing export metadata...")
|
|
423
|
+
await progress.increment()
|
|
424
|
+
|
|
425
|
+
handler = get_download_handler()
|
|
426
|
+
|
|
427
|
+
# Get active profile for scoped storage
|
|
428
|
+
auth_mgr = get_auth_manager()
|
|
429
|
+
profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
|
|
430
|
+
|
|
431
|
+
# Determine export type from ID
|
|
432
|
+
try:
|
|
433
|
+
padded = export_id + "=" * (4 - len(export_id) % 4)
|
|
434
|
+
decoded = base64.b64decode(padded).decode("utf-8")
|
|
435
|
+
if "," in decoded:
|
|
436
|
+
_, suffix = decoded.rsplit(",", 1)
|
|
437
|
+
type_map = {
|
|
438
|
+
"C": "campaigns",
|
|
439
|
+
"A": "adgroups",
|
|
440
|
+
"AD": "ads",
|
|
441
|
+
"T": "targets",
|
|
442
|
+
}
|
|
443
|
+
export_type = type_map.get(suffix.upper(), "general")
|
|
444
|
+
else:
|
|
445
|
+
export_type = "general"
|
|
446
|
+
except (AttributeError, TypeError, ValueError):
|
|
447
|
+
export_type = "general"
|
|
448
|
+
|
|
449
|
+
# Report progress: downloading
|
|
450
|
+
await progress.set_message(f"Downloading {export_type} export...")
|
|
451
|
+
await progress.increment()
|
|
452
|
+
|
|
453
|
+
file_path = await handler.download_export(
|
|
454
|
+
export_url=export_url,
|
|
455
|
+
export_id=export_id,
|
|
456
|
+
export_type=export_type,
|
|
457
|
+
profile_id=profile_id,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Report progress: complete
|
|
461
|
+
await progress.set_message("Download complete!")
|
|
462
|
+
await progress.increment()
|
|
463
|
+
|
|
464
|
+
return DownloadExportResponse(
|
|
465
|
+
success=True,
|
|
466
|
+
file_path=str(file_path),
|
|
467
|
+
export_type=export_type,
|
|
468
|
+
message=f"Export downloaded to {file_path}",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
@server.tool(
|
|
472
|
+
name="list_downloads",
|
|
473
|
+
description="List all downloaded exports and reports for the active profile",
|
|
474
|
+
)
|
|
475
|
+
async def list_downloads_tool(
|
|
476
|
+
ctx: Context, resource_type: Optional[str] = None
|
|
477
|
+
) -> ListDownloadsResponse:
|
|
478
|
+
"""List downloaded files for the active profile."""
|
|
479
|
+
from ..tools.download_tools import list_downloaded_files
|
|
480
|
+
|
|
481
|
+
# Get active profile for scoped listing
|
|
482
|
+
auth_mgr = get_auth_manager()
|
|
483
|
+
profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
|
|
484
|
+
|
|
485
|
+
result = await list_downloaded_files(resource_type, profile_id=profile_id)
|
|
486
|
+
|
|
487
|
+
# Transform flat file list into DownloadedFile objects
|
|
488
|
+
files = []
|
|
489
|
+
for f in result.get("files", []):
|
|
490
|
+
# Extract resource_type from path (e.g., "exports/campaigns/file.json")
|
|
491
|
+
path_parts = f.get("path", "").split("/")
|
|
492
|
+
rtype = path_parts[0] if path_parts else "unknown"
|
|
493
|
+
|
|
494
|
+
files.append(
|
|
495
|
+
DownloadedFile(
|
|
496
|
+
filename=f.get("name", ""),
|
|
497
|
+
path=f.get("path", ""),
|
|
498
|
+
size=f.get("size", 0),
|
|
499
|
+
modified=f.get("modified", ""),
|
|
500
|
+
resource_type=rtype,
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
return ListDownloadsResponse(
|
|
505
|
+
success=True,
|
|
506
|
+
files=files,
|
|
507
|
+
count=result.get("total_files", len(files)),
|
|
508
|
+
download_dir=result.get("base_directory", ""),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
@server.tool(
|
|
512
|
+
name="get_download_url",
|
|
513
|
+
description="""Get the HTTP URL for downloading a file.
|
|
514
|
+
|
|
515
|
+
Use with list_downloads to find available files, then get their download URLs.
|
|
516
|
+
The URL can be opened in a browser or used with curl/wget to download the file.
|
|
517
|
+
|
|
518
|
+
Note: Requires HTTP transport (not stdio).
|
|
519
|
+
""",
|
|
520
|
+
)
|
|
521
|
+
async def get_download_url_tool(
|
|
522
|
+
ctx: Context,
|
|
523
|
+
file_path: str,
|
|
524
|
+
) -> GetDownloadUrlResponse:
|
|
525
|
+
"""Generate the download URL for a file.
|
|
526
|
+
|
|
527
|
+
:param ctx: MCP context
|
|
528
|
+
:param file_path: Relative path from list_downloads output
|
|
529
|
+
:return: Response with download URL
|
|
530
|
+
"""
|
|
531
|
+
from pathlib import Path
|
|
532
|
+
from urllib.parse import quote
|
|
533
|
+
|
|
534
|
+
# Try to get HTTP request context
|
|
535
|
+
try:
|
|
536
|
+
from fastmcp.server.dependencies import get_http_request
|
|
537
|
+
|
|
538
|
+
request = get_http_request()
|
|
539
|
+
except (ImportError, RuntimeError):
|
|
540
|
+
return GetDownloadUrlResponse(
|
|
541
|
+
success=False,
|
|
542
|
+
error="HTTP transport required for file downloads",
|
|
543
|
+
hint="Run server with --transport http",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Get current profile
|
|
547
|
+
auth_mgr = get_auth_manager()
|
|
548
|
+
profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
|
|
549
|
+
|
|
550
|
+
if not profile_id:
|
|
551
|
+
return GetDownloadUrlResponse(
|
|
552
|
+
success=False,
|
|
553
|
+
error="No active profile",
|
|
554
|
+
hint="Set active profile before getting download URLs",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Validate file exists
|
|
558
|
+
from ..utils.export_download_handler import get_download_handler
|
|
559
|
+
|
|
560
|
+
handler = get_download_handler()
|
|
561
|
+
profile_dir = handler.base_dir / "profiles" / profile_id
|
|
562
|
+
full_path = profile_dir / file_path
|
|
563
|
+
|
|
564
|
+
if not full_path.exists():
|
|
565
|
+
return GetDownloadUrlResponse(
|
|
566
|
+
success=False,
|
|
567
|
+
error="File not found",
|
|
568
|
+
hint="Use list_downloads to see available files",
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Build URL with proper encoding
|
|
572
|
+
base_url = str(request.base_url).rstrip("/")
|
|
573
|
+
|
|
574
|
+
# Handle forwarded headers from reverse proxy
|
|
575
|
+
forwarded_proto = request.headers.get("X-Forwarded-Proto")
|
|
576
|
+
forwarded_host = request.headers.get("X-Forwarded-Host")
|
|
577
|
+
if forwarded_proto and forwarded_host:
|
|
578
|
+
base_url = f"{forwarded_proto}://{forwarded_host}"
|
|
579
|
+
|
|
580
|
+
# URL-encode path segments
|
|
581
|
+
encoded_path = "/".join(
|
|
582
|
+
quote(part, safe="") for part in Path(file_path).parts
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
download_url = f"{base_url}/downloads/{encoded_path}"
|
|
586
|
+
return GetDownloadUrlResponse(
|
|
587
|
+
success=True,
|
|
588
|
+
download_url=download_url,
|
|
589
|
+
file_name=full_path.name,
|
|
590
|
+
size_bytes=full_path.stat().st_size,
|
|
591
|
+
profile_id=profile_id,
|
|
592
|
+
instructions=(
|
|
593
|
+
f"Use HTTP GET to download: curl -O '{download_url}'. "
|
|
594
|
+
"If authentication is enabled, add header: "
|
|
595
|
+
"Authorization: Bearer <token>"
|
|
596
|
+
),
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def register_reporting_tools(server: FastMCP):
|
|
601
|
+
"""Register reporting workflow tools with background task support.
|
|
602
|
+
|
|
603
|
+
These wrapper tools orchestrate the full report workflow (request → poll → download)
|
|
604
|
+
with progress tracking. OpenAPI-generated tools cannot have task=True, so we
|
|
605
|
+
create builtin wrappers for long-running operations.
|
|
606
|
+
|
|
607
|
+
:param server: FastMCP server instance
|
|
608
|
+
:type server: FastMCP
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
@server.tool(
|
|
612
|
+
name="request_async_report",
|
|
613
|
+
description="Request and download an async report with progress tracking (V3 Reporting API)",
|
|
614
|
+
task=True, # Enable background task execution
|
|
615
|
+
)
|
|
616
|
+
async def request_async_report_tool(
|
|
617
|
+
ctx: Context,
|
|
618
|
+
report_type: str,
|
|
619
|
+
start_date: str,
|
|
620
|
+
end_date: str,
|
|
621
|
+
time_unit: str = "DAILY",
|
|
622
|
+
group_by: Optional[str] = None,
|
|
623
|
+
columns: Optional[str] = None,
|
|
624
|
+
filters: Optional[str] = None,
|
|
625
|
+
poll_interval_seconds: int = 10,
|
|
626
|
+
max_poll_attempts: int = 60,
|
|
627
|
+
progress: Progress = Progress(),
|
|
628
|
+
) -> AsyncReportResponse:
|
|
629
|
+
"""Request an async report and wait for completion with progress tracking.
|
|
630
|
+
|
|
631
|
+
This tool handles the full V3 Reporting API workflow:
|
|
632
|
+
1. Creates a report request
|
|
633
|
+
2. Polls for completion with progress updates
|
|
634
|
+
3. Downloads the completed report
|
|
635
|
+
4. Returns the local file path
|
|
636
|
+
|
|
637
|
+
Supports background execution - clients can track progress while report
|
|
638
|
+
generates in the background.
|
|
639
|
+
|
|
640
|
+
:param report_type: Report type (e.g., spCampaigns, spTargeting, sbCampaigns)
|
|
641
|
+
:param start_date: Report start date (YYYY-MM-DD)
|
|
642
|
+
:param end_date: Report end date (YYYY-MM-DD)
|
|
643
|
+
:param time_unit: Time granularity (DAILY, SUMMARY)
|
|
644
|
+
:param group_by: Comma-separated list of dimensions to group by
|
|
645
|
+
:param columns: Comma-separated list of columns to include
|
|
646
|
+
:param filters: JSON string of filters to apply
|
|
647
|
+
:param poll_interval_seconds: Seconds between status checks (default: 10)
|
|
648
|
+
:param max_poll_attempts: Maximum polling attempts before timeout (default: 60)
|
|
649
|
+
:return: Report result with file path
|
|
650
|
+
"""
|
|
651
|
+
import asyncio
|
|
652
|
+
import json as json_module
|
|
653
|
+
|
|
654
|
+
from ..utils.http_client import get_authenticated_client
|
|
655
|
+
|
|
656
|
+
# Total steps: create (1) + poll (variable) + download (1)
|
|
657
|
+
await progress.set_total(max_poll_attempts + 2)
|
|
658
|
+
await progress.set_message("Creating report request...")
|
|
659
|
+
await progress.increment()
|
|
660
|
+
|
|
661
|
+
# Get HTTP client for API calls
|
|
662
|
+
client = await get_authenticated_client()
|
|
663
|
+
|
|
664
|
+
# Build report request body
|
|
665
|
+
report_config = {
|
|
666
|
+
"reportType": report_type,
|
|
667
|
+
"startDate": start_date,
|
|
668
|
+
"endDate": end_date,
|
|
669
|
+
"timeUnit": time_unit,
|
|
670
|
+
"format": "GZIP_JSON",
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if group_by:
|
|
674
|
+
report_config["groupBy"] = [g.strip() for g in group_by.split(",")]
|
|
675
|
+
if columns:
|
|
676
|
+
report_config["columns"] = [c.strip() for c in columns.split(",")]
|
|
677
|
+
if filters:
|
|
678
|
+
try:
|
|
679
|
+
report_config["filters"] = json_module.loads(filters)
|
|
680
|
+
except json_module.JSONDecodeError:
|
|
681
|
+
return AsyncReportResponse(
|
|
682
|
+
success=False, error="Invalid JSON in filters parameter"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# Create report request
|
|
686
|
+
try:
|
|
687
|
+
response = await client.post("/reporting/reports", json=report_config)
|
|
688
|
+
response.raise_for_status()
|
|
689
|
+
create_result = response.json()
|
|
690
|
+
report_id = create_result.get("reportId")
|
|
691
|
+
|
|
692
|
+
if not report_id:
|
|
693
|
+
return AsyncReportResponse(
|
|
694
|
+
success=False, error="No reportId in response"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
except Exception as e:
|
|
698
|
+
return AsyncReportResponse(
|
|
699
|
+
success=False, error=f"Failed to create report: {e}"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
await progress.set_message(f"Report created: {report_id}")
|
|
703
|
+
|
|
704
|
+
# Poll for completion
|
|
705
|
+
download_url = None
|
|
706
|
+
for attempt in range(max_poll_attempts):
|
|
707
|
+
await progress.set_message(f"Checking status... (attempt {attempt + 1})")
|
|
708
|
+
await progress.increment()
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
status_response = await client.get(f"/reporting/reports/{report_id}")
|
|
712
|
+
status_response.raise_for_status()
|
|
713
|
+
status_data = status_response.json()
|
|
714
|
+
|
|
715
|
+
status = status_data.get("status", "UNKNOWN")
|
|
716
|
+
|
|
717
|
+
if status == "COMPLETED":
|
|
718
|
+
download_url = status_data.get("url")
|
|
719
|
+
break
|
|
720
|
+
elif status == "FAILED":
|
|
721
|
+
error_details = status_data.get("failureReason", "Unknown error")
|
|
722
|
+
return AsyncReportResponse(
|
|
723
|
+
success=False,
|
|
724
|
+
report_id=report_id,
|
|
725
|
+
status=status,
|
|
726
|
+
error=f"Report failed: {error_details}",
|
|
727
|
+
)
|
|
728
|
+
elif status in ("PENDING", "PROCESSING", "IN_PROGRESS"):
|
|
729
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
730
|
+
else:
|
|
731
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
732
|
+
|
|
733
|
+
except Exception as e:
|
|
734
|
+
logger.warning(f"Error checking report status: {e}")
|
|
735
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
736
|
+
|
|
737
|
+
if not download_url:
|
|
738
|
+
return AsyncReportResponse(
|
|
739
|
+
success=False,
|
|
740
|
+
report_id=report_id,
|
|
741
|
+
error=f"Report did not complete within {max_poll_attempts * poll_interval_seconds} seconds",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# Download the report
|
|
745
|
+
await progress.set_message("Downloading report...")
|
|
746
|
+
await progress.increment()
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
from ..utils.export_download_handler import get_download_handler
|
|
750
|
+
|
|
751
|
+
handler = get_download_handler()
|
|
752
|
+
|
|
753
|
+
# Get active profile for scoped storage
|
|
754
|
+
auth_mgr = get_auth_manager()
|
|
755
|
+
profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
|
|
756
|
+
|
|
757
|
+
file_path = await handler.download_export(
|
|
758
|
+
export_url=download_url,
|
|
759
|
+
export_id=report_id,
|
|
760
|
+
export_type=f"report_{report_type}",
|
|
761
|
+
metadata={"report_config": report_config},
|
|
762
|
+
profile_id=profile_id,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
await progress.set_message("Report download complete!")
|
|
766
|
+
|
|
767
|
+
return AsyncReportResponse(
|
|
768
|
+
success=True,
|
|
769
|
+
report_id=report_id,
|
|
770
|
+
report_type=report_type,
|
|
771
|
+
file_path=str(file_path),
|
|
772
|
+
message=f"Report downloaded to {file_path}",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
except Exception as e:
|
|
776
|
+
return AsyncReportResponse(
|
|
777
|
+
success=False,
|
|
778
|
+
report_id=report_id,
|
|
779
|
+
error=f"Failed to download report: {e}",
|
|
780
|
+
download_url=download_url,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
logger.info("Registered reporting workflow tools with background task support")
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
async def register_sampling_tools(server: FastMCP):
|
|
787
|
+
"""Register sampling tools if sampling is enabled.
|
|
788
|
+
|
|
789
|
+
:param server: FastMCP server instance
|
|
790
|
+
:type server: FastMCP
|
|
791
|
+
"""
|
|
792
|
+
if not settings.enable_sampling:
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
@server.tool(
|
|
796
|
+
name="test_sampling",
|
|
797
|
+
description="Test LLM sampling functionality via MCP client",
|
|
798
|
+
)
|
|
799
|
+
async def test_sampling_tool(
|
|
800
|
+
ctx: Context,
|
|
801
|
+
message: str = "Hello, please summarize this test message",
|
|
802
|
+
) -> SamplingTestResponse:
|
|
803
|
+
"""Test the native MCP sampling functionality.
|
|
804
|
+
|
|
805
|
+
Uses FastMCP 2.14.1+ native ctx.sample() directly. This requires
|
|
806
|
+
the MCP client to support sampling (createMessage capability).
|
|
807
|
+
|
|
808
|
+
The sampling flow:
|
|
809
|
+
1. Tool sends sampling request to client via ctx.sample()
|
|
810
|
+
2. Client's LLM generates a response
|
|
811
|
+
3. Response is returned to the tool
|
|
812
|
+
|
|
813
|
+
Note: If the client doesn't support sampling, an error will be returned.
|
|
814
|
+
Server-side fallback is available when SAMPLING_ENABLED=true and
|
|
815
|
+
OPENAI_API_KEY is configured.
|
|
816
|
+
"""
|
|
817
|
+
try:
|
|
818
|
+
# Use native ctx.sample() - FastMCP 2.14.1+
|
|
819
|
+
result = await ctx.sample(
|
|
820
|
+
messages=message,
|
|
821
|
+
system_prompt="You are a helpful assistant. Provide a brief summary.",
|
|
822
|
+
temperature=0.7,
|
|
823
|
+
max_tokens=100,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
# Extract text from result
|
|
827
|
+
response_text = result.text if hasattr(result, "text") else str(result)
|
|
828
|
+
|
|
829
|
+
return SamplingTestResponse(
|
|
830
|
+
success=True,
|
|
831
|
+
message="Sampling executed successfully via native ctx.sample()",
|
|
832
|
+
response=response_text,
|
|
833
|
+
sampling_enabled=settings.enable_sampling,
|
|
834
|
+
)
|
|
835
|
+
except Exception as e:
|
|
836
|
+
error_msg = str(e).lower()
|
|
837
|
+
|
|
838
|
+
# Check if it's a "client doesn't support sampling" error
|
|
839
|
+
if "does not support sampling" in error_msg or "sampling not supported" in error_msg:
|
|
840
|
+
# Try server-side fallback if enabled
|
|
841
|
+
if settings.enable_sampling:
|
|
842
|
+
try:
|
|
843
|
+
from ..utils.sampling_helpers import sample_with_fallback
|
|
844
|
+
|
|
845
|
+
result = await sample_with_fallback(
|
|
846
|
+
ctx=ctx,
|
|
847
|
+
messages=message,
|
|
848
|
+
system_prompt="You are a helpful assistant. Provide a brief summary.",
|
|
849
|
+
temperature=0.7,
|
|
850
|
+
max_tokens=100,
|
|
851
|
+
)
|
|
852
|
+
response_text = result.text if hasattr(result, "text") else str(result)
|
|
853
|
+
|
|
854
|
+
return SamplingTestResponse(
|
|
855
|
+
success=True,
|
|
856
|
+
message="Sampling executed via server-side fallback",
|
|
857
|
+
response=response_text,
|
|
858
|
+
sampling_enabled=True,
|
|
859
|
+
used_fallback="Server-side OpenAI fallback was used",
|
|
860
|
+
)
|
|
861
|
+
except Exception as fallback_error:
|
|
862
|
+
logger.error(f"Server-side fallback failed: {fallback_error}")
|
|
863
|
+
return SamplingTestResponse(
|
|
864
|
+
success=False,
|
|
865
|
+
error=f"Both client and server sampling failed: {fallback_error}",
|
|
866
|
+
sampling_enabled=True,
|
|
867
|
+
note="Check OPENAI_API_KEY environment variable",
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
return SamplingTestResponse(
|
|
871
|
+
success=False,
|
|
872
|
+
error="Client does not support sampling",
|
|
873
|
+
sampling_enabled=False,
|
|
874
|
+
note="Enable server-side fallback with SAMPLING_ENABLED=true and OPENAI_API_KEY",
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
logger.error(f"Sampling test failed: {e}")
|
|
878
|
+
return SamplingTestResponse(
|
|
879
|
+
success=False,
|
|
880
|
+
error=str(e),
|
|
881
|
+
sampling_enabled=settings.enable_sampling,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
async def register_oauth_tools_builtin(server: FastMCP):
|
|
886
|
+
"""Register OAuth authentication tools.
|
|
887
|
+
|
|
888
|
+
:param server: FastMCP server instance.
|
|
889
|
+
"""
|
|
890
|
+
oauth = OAuthTools(settings)
|
|
891
|
+
|
|
892
|
+
@server.tool(
|
|
893
|
+
name="start_oauth_flow",
|
|
894
|
+
description="Start the OAuth authorization flow for Amazon Ads API",
|
|
895
|
+
)
|
|
896
|
+
async def start_oauth_flow(ctx: Context):
|
|
897
|
+
"""Start the OAuth authorization flow."""
|
|
898
|
+
return await oauth.start_oauth_flow(ctx)
|
|
899
|
+
|
|
900
|
+
@server.tool(
|
|
901
|
+
name="check_oauth_status",
|
|
902
|
+
description="Check the current OAuth authentication status",
|
|
903
|
+
)
|
|
904
|
+
async def check_oauth_status(ctx: Context):
|
|
905
|
+
"""Check OAuth authentication status."""
|
|
906
|
+
return await oauth.check_oauth_status(ctx)
|
|
907
|
+
|
|
908
|
+
@server.tool(
|
|
909
|
+
name="refresh_oauth_token",
|
|
910
|
+
description="Manually refresh the OAuth access token",
|
|
911
|
+
)
|
|
912
|
+
async def refresh_oauth_token(ctx: Context):
|
|
913
|
+
"""Refresh OAuth access token."""
|
|
914
|
+
return await oauth.refresh_access_token(ctx)
|
|
915
|
+
|
|
916
|
+
@server.tool(
|
|
917
|
+
name="clear_oauth_tokens",
|
|
918
|
+
description="Clear all stored OAuth tokens and state",
|
|
919
|
+
)
|
|
920
|
+
async def clear_oauth_tokens(ctx: Context):
|
|
921
|
+
"""Clear OAuth tokens."""
|
|
922
|
+
return await oauth.clear_oauth_tokens(ctx)
|
|
923
|
+
|
|
924
|
+
logger.info("Registered OAuth authentication tools")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# Removed cache tools - not core operations
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
# Removed diagnostic tools - not core operations
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
async def register_all_builtin_tools(server: FastMCP):
|
|
934
|
+
"""Register all built-in tools with the server.
|
|
935
|
+
|
|
936
|
+
:param server: FastMCP server instance.
|
|
937
|
+
"""
|
|
938
|
+
# Register common tools that work for all auth types
|
|
939
|
+
await register_profile_tools(server)
|
|
940
|
+
await register_profile_listing_tools(server)
|
|
941
|
+
await register_region_tools(server)
|
|
942
|
+
# Routing tools removed - override functionality was redundant
|
|
943
|
+
await register_download_tools(server)
|
|
944
|
+
await register_reporting_tools(server)
|
|
945
|
+
await register_sampling_tools(server)
|
|
946
|
+
# Cache & diagnostic tools removed - not core operations
|
|
947
|
+
|
|
948
|
+
# Register auth-specific tools based on provider type
|
|
949
|
+
auth_mgr = get_auth_manager()
|
|
950
|
+
if auth_mgr and auth_mgr.provider:
|
|
951
|
+
# Check provider_type property (not auth_method attribute)
|
|
952
|
+
if hasattr(auth_mgr.provider, "provider_type"):
|
|
953
|
+
if auth_mgr.provider.provider_type == "direct":
|
|
954
|
+
# Direct OAuth authentication tools
|
|
955
|
+
await register_oauth_tools_builtin(server)
|
|
956
|
+
logger.info("Registered OAuth authentication tools")
|
|
957
|
+
elif auth_mgr.provider.provider_type == "openbridge":
|
|
958
|
+
# OpenBridge identity management tools
|
|
959
|
+
await register_identity_tools(server)
|
|
960
|
+
logger.info("Registered OpenBridge identity tools")
|
|
961
|
+
|
|
962
|
+
logger.info("Registered all built-in tools")
|