airbyte-internal-ops 0.9.1__py3-none-any.whl → 0.10.1__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.
- {airbyte_internal_ops-0.9.1.dist-info → airbyte_internal_ops-0.10.1.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.9.1.dist-info → airbyte_internal_ops-0.10.1.dist-info}/RECORD +13 -11
- airbyte_ops_mcp/airbyte_repo/progressive_rollout.py +279 -0
- airbyte_ops_mcp/cli/local.py +118 -0
- airbyte_ops_mcp/cloud_admin/api_client.py +406 -0
- airbyte_ops_mcp/cloud_admin/models.py +99 -0
- airbyte_ops_mcp/mcp/connector_rollout.py +882 -0
- airbyte_ops_mcp/mcp/prod_db_queries.py +140 -0
- airbyte_ops_mcp/mcp/server.py +2 -0
- airbyte_ops_mcp/prod_db_access/queries.py +83 -0
- airbyte_ops_mcp/prod_db_access/sql.py +207 -0
- {airbyte_internal_ops-0.9.1.dist-info → airbyte_internal_ops-0.10.1.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.9.1.dist-info → airbyte_internal_ops-0.10.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""MCP tools for connector rollout management.
|
|
3
|
+
|
|
4
|
+
This module provides MCP tools for managing connector rollouts in Airbyte Cloud,
|
|
5
|
+
including finalizing (promoting, rolling back, or canceling) rollouts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# NOTE: We intentionally do NOT use `from __future__ import annotations` here.
|
|
9
|
+
# FastMCP has issues resolving forward references when PEP 563 deferred annotations
|
|
10
|
+
# are used. See: https://github.com/jlowin/fastmcp/issues/905
|
|
11
|
+
# Python 3.12+ supports modern type hint syntax natively, so this is not needed.
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Annotated, Literal
|
|
15
|
+
|
|
16
|
+
from airbyte import constants
|
|
17
|
+
from airbyte.exceptions import PyAirbyteInputError
|
|
18
|
+
from fastmcp import Context, FastMCP
|
|
19
|
+
from fastmcp_extensions import get_mcp_config, mcp_tool, register_mcp_tools
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from airbyte_ops_mcp.cloud_admin import api_client
|
|
23
|
+
from airbyte_ops_mcp.cloud_admin.auth import (
|
|
24
|
+
CloudAuthError,
|
|
25
|
+
require_internal_admin_flag_only,
|
|
26
|
+
)
|
|
27
|
+
from airbyte_ops_mcp.cloud_admin.models import (
|
|
28
|
+
ConnectorRolloutFinalizeResult,
|
|
29
|
+
ConnectorRolloutProgressResult,
|
|
30
|
+
ConnectorRolloutStartResult,
|
|
31
|
+
)
|
|
32
|
+
from airbyte_ops_mcp.constants import ServerConfigKey
|
|
33
|
+
from airbyte_ops_mcp.github_api import (
|
|
34
|
+
GitHubAPIError,
|
|
35
|
+
GitHubCommentParseError,
|
|
36
|
+
GitHubUserEmailNotFoundError,
|
|
37
|
+
get_admin_email_from_approval_comment,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class _ResolvedCloudAuth:
|
|
43
|
+
"""Resolved authentication for Airbyte Cloud API calls.
|
|
44
|
+
|
|
45
|
+
Either bearer_token OR (client_id AND client_secret) will be set, not both.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
bearer_token: str | None = None
|
|
49
|
+
client_id: str | None = None
|
|
50
|
+
client_secret: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_cloud_auth(ctx: Context) -> _ResolvedCloudAuth:
|
|
54
|
+
"""Resolve authentication credentials for Airbyte Cloud API.
|
|
55
|
+
|
|
56
|
+
Credentials are resolved in priority order:
|
|
57
|
+
1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
|
|
58
|
+
2. Client credentials (X-Airbyte-Cloud-Client-Id/Secret headers or env vars)
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
ctx: FastMCP Context object from the current tool invocation.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
_ResolvedCloudAuth with either bearer_token or client credentials set.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
CloudAuthError: If credentials cannot be resolved from headers or env vars.
|
|
68
|
+
"""
|
|
69
|
+
# Try bearer token first (preferred, but not required)
|
|
70
|
+
bearer_token = get_mcp_config(ctx, ServerConfigKey.BEARER_TOKEN)
|
|
71
|
+
if bearer_token:
|
|
72
|
+
return _ResolvedCloudAuth(bearer_token=bearer_token)
|
|
73
|
+
|
|
74
|
+
# Fall back to client credentials
|
|
75
|
+
try:
|
|
76
|
+
client_id = get_mcp_config(ctx, ServerConfigKey.CLIENT_ID)
|
|
77
|
+
client_secret = get_mcp_config(ctx, ServerConfigKey.CLIENT_SECRET)
|
|
78
|
+
return _ResolvedCloudAuth(
|
|
79
|
+
client_id=client_id,
|
|
80
|
+
client_secret=client_secret,
|
|
81
|
+
)
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
raise CloudAuthError(
|
|
84
|
+
f"Failed to resolve credentials. Ensure credentials are provided "
|
|
85
|
+
f"via Authorization header (Bearer token), "
|
|
86
|
+
f"HTTP headers (X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret), "
|
|
87
|
+
f"or environment variables. Error: {e}"
|
|
88
|
+
) from e
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mcp_tool(
|
|
92
|
+
destructive=True,
|
|
93
|
+
idempotent=False,
|
|
94
|
+
open_world=True,
|
|
95
|
+
)
|
|
96
|
+
def start_connector_rollout(
|
|
97
|
+
docker_repository: Annotated[
|
|
98
|
+
str,
|
|
99
|
+
Field(description="The docker repository (e.g., 'airbyte/source-pokeapi')"),
|
|
100
|
+
],
|
|
101
|
+
docker_image_tag: Annotated[
|
|
102
|
+
str,
|
|
103
|
+
Field(description="The docker image tag (e.g., '0.3.48-rc.1')"),
|
|
104
|
+
],
|
|
105
|
+
actor_definition_id: Annotated[
|
|
106
|
+
str,
|
|
107
|
+
Field(description="The actor definition ID (UUID)"),
|
|
108
|
+
],
|
|
109
|
+
approval_comment_url: Annotated[
|
|
110
|
+
str,
|
|
111
|
+
Field(
|
|
112
|
+
description="URL to a GitHub comment where an Airbyte contributor has "
|
|
113
|
+
"explicitly requested or authorized starting this rollout. "
|
|
114
|
+
"Must be a valid GitHub comment URL (containing #issuecomment- or #discussion_r). "
|
|
115
|
+
"The admin email is automatically derived from the comment author's GitHub profile."
|
|
116
|
+
),
|
|
117
|
+
],
|
|
118
|
+
rollout_strategy: Annotated[
|
|
119
|
+
Literal["manual", "automated", "overridden"],
|
|
120
|
+
Field(
|
|
121
|
+
description="The rollout strategy: "
|
|
122
|
+
"'manual' for manual control of rollout progression, "
|
|
123
|
+
"'automated' for automatic progression based on metrics, "
|
|
124
|
+
"'overridden' for special cases where normal rules are bypassed.",
|
|
125
|
+
default="manual",
|
|
126
|
+
),
|
|
127
|
+
] = "manual",
|
|
128
|
+
initial_rollout_pct: Annotated[
|
|
129
|
+
int | None,
|
|
130
|
+
Field(
|
|
131
|
+
description="Initial/step percentage for rollout progression (0-100). "
|
|
132
|
+
"For automated rollouts, this is the percentage increment per step. "
|
|
133
|
+
"For example, 25 means the rollout will advance by 25% each step. "
|
|
134
|
+
"Default is 25% if not specified.",
|
|
135
|
+
default=None,
|
|
136
|
+
),
|
|
137
|
+
] = None,
|
|
138
|
+
final_target_rollout_pct: Annotated[
|
|
139
|
+
int | None,
|
|
140
|
+
Field(
|
|
141
|
+
description="Maximum percentage of actors to pin (0-100). "
|
|
142
|
+
"The rollout will not exceed this percentage. "
|
|
143
|
+
"For example, 50 means at most 50% of actors will be pinned to the RC. "
|
|
144
|
+
"Default is 50% if not specified.",
|
|
145
|
+
default=None,
|
|
146
|
+
),
|
|
147
|
+
] = None,
|
|
148
|
+
*,
|
|
149
|
+
ctx: Context,
|
|
150
|
+
) -> ConnectorRolloutStartResult:
|
|
151
|
+
"""Start or configure a connector rollout workflow.
|
|
152
|
+
|
|
153
|
+
This tool configures and starts a connector rollout workflow. It can be called
|
|
154
|
+
multiple times while the rollout is in INITIALIZED state to update the configuration
|
|
155
|
+
(strategy, percentages). Once the Temporal workflow starts and the state transitions
|
|
156
|
+
to WORKFLOW_STARTED, the configuration is locked and cannot be changed.
|
|
157
|
+
|
|
158
|
+
**Behavior:**
|
|
159
|
+
- If rollout is INITIALIZED: Updates configuration and starts the workflow
|
|
160
|
+
- If rollout is already started: Returns an error (configuration is locked)
|
|
161
|
+
|
|
162
|
+
**Configuration Parameters:**
|
|
163
|
+
- rollout_strategy: 'manual' (default), 'automated', or 'overridden'
|
|
164
|
+
- initial_rollout_pct: Step size for progression (default: 25%)
|
|
165
|
+
- final_target_rollout_pct: Maximum percentage to pin (default: 50%)
|
|
166
|
+
|
|
167
|
+
**Admin-only operation** - Requires:
|
|
168
|
+
- AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
|
|
169
|
+
- approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
|
|
170
|
+
|
|
171
|
+
The admin user email is automatically derived from the approval_comment_url by:
|
|
172
|
+
1. Fetching the comment from GitHub API to get the author's username
|
|
173
|
+
2. Fetching the user's profile to get their public email
|
|
174
|
+
3. Validating the email is an @airbyte.io address
|
|
175
|
+
"""
|
|
176
|
+
# Validate admin access (check env var flag)
|
|
177
|
+
try:
|
|
178
|
+
require_internal_admin_flag_only()
|
|
179
|
+
except CloudAuthError as e:
|
|
180
|
+
return ConnectorRolloutStartResult(
|
|
181
|
+
success=False,
|
|
182
|
+
message=f"Admin authentication failed: {e}",
|
|
183
|
+
docker_repository=docker_repository,
|
|
184
|
+
docker_image_tag=docker_image_tag,
|
|
185
|
+
actor_definition_id=actor_definition_id,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Validate approval_comment_url format
|
|
189
|
+
if not approval_comment_url.startswith("https://github.com/"):
|
|
190
|
+
return ConnectorRolloutStartResult(
|
|
191
|
+
success=False,
|
|
192
|
+
message=f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}",
|
|
193
|
+
docker_repository=docker_repository,
|
|
194
|
+
docker_image_tag=docker_image_tag,
|
|
195
|
+
actor_definition_id=actor_definition_id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
"#issuecomment-" not in approval_comment_url
|
|
200
|
+
and "#discussion_r" not in approval_comment_url
|
|
201
|
+
):
|
|
202
|
+
return ConnectorRolloutStartResult(
|
|
203
|
+
success=False,
|
|
204
|
+
message="approval_comment_url must be a GitHub comment URL "
|
|
205
|
+
"(containing #issuecomment- or #discussion_r)",
|
|
206
|
+
docker_repository=docker_repository,
|
|
207
|
+
docker_image_tag=docker_image_tag,
|
|
208
|
+
actor_definition_id=actor_definition_id,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Derive admin email from approval comment URL
|
|
212
|
+
try:
|
|
213
|
+
admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
|
|
214
|
+
except GitHubCommentParseError as e:
|
|
215
|
+
return ConnectorRolloutStartResult(
|
|
216
|
+
success=False,
|
|
217
|
+
message=f"Failed to parse approval comment URL: {e}",
|
|
218
|
+
docker_repository=docker_repository,
|
|
219
|
+
docker_image_tag=docker_image_tag,
|
|
220
|
+
actor_definition_id=actor_definition_id,
|
|
221
|
+
)
|
|
222
|
+
except GitHubAPIError as e:
|
|
223
|
+
return ConnectorRolloutStartResult(
|
|
224
|
+
success=False,
|
|
225
|
+
message=f"Failed to fetch approval comment from GitHub: {e}",
|
|
226
|
+
docker_repository=docker_repository,
|
|
227
|
+
docker_image_tag=docker_image_tag,
|
|
228
|
+
actor_definition_id=actor_definition_id,
|
|
229
|
+
)
|
|
230
|
+
except GitHubUserEmailNotFoundError as e:
|
|
231
|
+
return ConnectorRolloutStartResult(
|
|
232
|
+
success=False,
|
|
233
|
+
message=str(e),
|
|
234
|
+
docker_repository=docker_repository,
|
|
235
|
+
docker_image_tag=docker_image_tag,
|
|
236
|
+
actor_definition_id=actor_definition_id,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Resolve auth credentials
|
|
240
|
+
try:
|
|
241
|
+
auth = _resolve_cloud_auth(ctx)
|
|
242
|
+
except CloudAuthError as e:
|
|
243
|
+
return ConnectorRolloutStartResult(
|
|
244
|
+
success=False,
|
|
245
|
+
message=f"Failed to resolve credentials: {e}",
|
|
246
|
+
docker_repository=docker_repository,
|
|
247
|
+
docker_image_tag=docker_image_tag,
|
|
248
|
+
actor_definition_id=actor_definition_id,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Get user ID from admin email
|
|
252
|
+
try:
|
|
253
|
+
user_id = api_client.get_user_id_by_email(
|
|
254
|
+
email=admin_user_email,
|
|
255
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
256
|
+
client_id=auth.client_id,
|
|
257
|
+
client_secret=auth.client_secret,
|
|
258
|
+
bearer_token=auth.bearer_token,
|
|
259
|
+
)
|
|
260
|
+
except PyAirbyteInputError as e:
|
|
261
|
+
return ConnectorRolloutStartResult(
|
|
262
|
+
success=False,
|
|
263
|
+
message=f"Failed to get user ID for admin email '{admin_user_email}': {e}",
|
|
264
|
+
docker_repository=docker_repository,
|
|
265
|
+
docker_image_tag=docker_image_tag,
|
|
266
|
+
actor_definition_id=actor_definition_id,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Call the API to start the rollout
|
|
270
|
+
try:
|
|
271
|
+
api_client.start_connector_rollout(
|
|
272
|
+
docker_repository=docker_repository,
|
|
273
|
+
docker_image_tag=docker_image_tag,
|
|
274
|
+
actor_definition_id=actor_definition_id,
|
|
275
|
+
updated_by=user_id,
|
|
276
|
+
rollout_strategy=rollout_strategy,
|
|
277
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
278
|
+
initial_rollout_pct=initial_rollout_pct,
|
|
279
|
+
final_target_rollout_pct=final_target_rollout_pct,
|
|
280
|
+
client_id=auth.client_id,
|
|
281
|
+
client_secret=auth.client_secret,
|
|
282
|
+
bearer_token=auth.bearer_token,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Build message with configuration details
|
|
286
|
+
config_details = []
|
|
287
|
+
if initial_rollout_pct is not None:
|
|
288
|
+
config_details.append(f"initial_rollout_pct={initial_rollout_pct}%")
|
|
289
|
+
if final_target_rollout_pct is not None:
|
|
290
|
+
config_details.append(
|
|
291
|
+
f"final_target_rollout_pct={final_target_rollout_pct}%"
|
|
292
|
+
)
|
|
293
|
+
config_str = (
|
|
294
|
+
f" Configuration: {', '.join(config_details)}." if config_details else ""
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return ConnectorRolloutStartResult(
|
|
298
|
+
success=True,
|
|
299
|
+
message=f"Successfully started rollout workflow for "
|
|
300
|
+
f"{docker_repository}:{docker_image_tag}. "
|
|
301
|
+
f"The rollout state has transitioned from INITIALIZED to WORKFLOW_STARTED."
|
|
302
|
+
f"{config_str}",
|
|
303
|
+
docker_repository=docker_repository,
|
|
304
|
+
docker_image_tag=docker_image_tag,
|
|
305
|
+
actor_definition_id=actor_definition_id,
|
|
306
|
+
rollout_strategy=rollout_strategy,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
except PyAirbyteInputError as e:
|
|
310
|
+
return ConnectorRolloutStartResult(
|
|
311
|
+
success=False,
|
|
312
|
+
message=str(e),
|
|
313
|
+
docker_repository=docker_repository,
|
|
314
|
+
docker_image_tag=docker_image_tag,
|
|
315
|
+
actor_definition_id=actor_definition_id,
|
|
316
|
+
)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
return ConnectorRolloutStartResult(
|
|
319
|
+
success=False,
|
|
320
|
+
message=f"Failed to start connector rollout: {e}",
|
|
321
|
+
docker_repository=docker_repository,
|
|
322
|
+
docker_image_tag=docker_image_tag,
|
|
323
|
+
actor_definition_id=actor_definition_id,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@mcp_tool(
|
|
328
|
+
destructive=True,
|
|
329
|
+
idempotent=False,
|
|
330
|
+
open_world=True,
|
|
331
|
+
)
|
|
332
|
+
def progress_connector_rollout(
|
|
333
|
+
docker_repository: Annotated[
|
|
334
|
+
str,
|
|
335
|
+
Field(description="The docker repository (e.g., 'airbyte/source-pokeapi')"),
|
|
336
|
+
],
|
|
337
|
+
docker_image_tag: Annotated[
|
|
338
|
+
str,
|
|
339
|
+
Field(description="The docker image tag (e.g., '0.3.48-rc.1')"),
|
|
340
|
+
],
|
|
341
|
+
actor_definition_id: Annotated[
|
|
342
|
+
str,
|
|
343
|
+
Field(description="The actor definition ID (UUID)"),
|
|
344
|
+
],
|
|
345
|
+
rollout_id: Annotated[
|
|
346
|
+
str,
|
|
347
|
+
Field(
|
|
348
|
+
description="The rollout ID (UUID). Can be found from query_prod_connector_rollouts."
|
|
349
|
+
),
|
|
350
|
+
],
|
|
351
|
+
approval_comment_url: Annotated[
|
|
352
|
+
str,
|
|
353
|
+
Field(
|
|
354
|
+
description="URL to a GitHub comment where an Airbyte contributor has "
|
|
355
|
+
"explicitly requested or authorized this rollout progression. "
|
|
356
|
+
"Must be a valid GitHub comment URL (containing #issuecomment- or #discussion_r). "
|
|
357
|
+
"The admin email is automatically derived from the comment author's GitHub profile."
|
|
358
|
+
),
|
|
359
|
+
],
|
|
360
|
+
target_percentage: Annotated[
|
|
361
|
+
int | None,
|
|
362
|
+
Field(
|
|
363
|
+
description="Target percentage of actors to pin to the RC (1-100). "
|
|
364
|
+
"Either target_percentage or actor_ids must be provided.",
|
|
365
|
+
default=None,
|
|
366
|
+
),
|
|
367
|
+
] = None,
|
|
368
|
+
actor_ids: Annotated[
|
|
369
|
+
list[str] | None,
|
|
370
|
+
Field(
|
|
371
|
+
description="Specific actor IDs to pin to the RC. "
|
|
372
|
+
"Either target_percentage or actor_ids must be provided.",
|
|
373
|
+
default=None,
|
|
374
|
+
),
|
|
375
|
+
] = None,
|
|
376
|
+
*,
|
|
377
|
+
ctx: Context,
|
|
378
|
+
) -> ConnectorRolloutProgressResult:
|
|
379
|
+
"""Progress a connector rollout by pinning actors to the RC version.
|
|
380
|
+
|
|
381
|
+
This tool progresses a connector rollout by either:
|
|
382
|
+
- Setting a target percentage of actors to pin to the RC version
|
|
383
|
+
- Specifying specific actor IDs to pin
|
|
384
|
+
|
|
385
|
+
**Admin-only operation** - Requires:
|
|
386
|
+
- AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
|
|
387
|
+
- approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
|
|
388
|
+
|
|
389
|
+
The admin user email is automatically derived from the approval_comment_url.
|
|
390
|
+
"""
|
|
391
|
+
# Validate admin access (check env var flag)
|
|
392
|
+
try:
|
|
393
|
+
require_internal_admin_flag_only()
|
|
394
|
+
except CloudAuthError as e:
|
|
395
|
+
return ConnectorRolloutProgressResult(
|
|
396
|
+
success=False,
|
|
397
|
+
message=f"Admin authentication failed: {e}",
|
|
398
|
+
rollout_id=rollout_id,
|
|
399
|
+
docker_repository=docker_repository,
|
|
400
|
+
docker_image_tag=docker_image_tag,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Validate that at least one of target_percentage or actor_ids is provided
|
|
404
|
+
if target_percentage is None and actor_ids is None:
|
|
405
|
+
return ConnectorRolloutProgressResult(
|
|
406
|
+
success=False,
|
|
407
|
+
message="Either target_percentage or actor_ids must be provided",
|
|
408
|
+
rollout_id=rollout_id,
|
|
409
|
+
docker_repository=docker_repository,
|
|
410
|
+
docker_image_tag=docker_image_tag,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Validate approval_comment_url format
|
|
414
|
+
if not approval_comment_url.startswith("https://github.com/"):
|
|
415
|
+
return ConnectorRolloutProgressResult(
|
|
416
|
+
success=False,
|
|
417
|
+
message=f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}",
|
|
418
|
+
rollout_id=rollout_id,
|
|
419
|
+
docker_repository=docker_repository,
|
|
420
|
+
docker_image_tag=docker_image_tag,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if (
|
|
424
|
+
"#issuecomment-" not in approval_comment_url
|
|
425
|
+
and "#discussion_r" not in approval_comment_url
|
|
426
|
+
):
|
|
427
|
+
return ConnectorRolloutProgressResult(
|
|
428
|
+
success=False,
|
|
429
|
+
message="approval_comment_url must be a GitHub comment URL "
|
|
430
|
+
"(containing #issuecomment- or #discussion_r)",
|
|
431
|
+
rollout_id=rollout_id,
|
|
432
|
+
docker_repository=docker_repository,
|
|
433
|
+
docker_image_tag=docker_image_tag,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Derive admin email from approval comment URL
|
|
437
|
+
try:
|
|
438
|
+
admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
|
|
439
|
+
except GitHubCommentParseError as e:
|
|
440
|
+
return ConnectorRolloutProgressResult(
|
|
441
|
+
success=False,
|
|
442
|
+
message=f"Failed to parse approval comment URL: {e}",
|
|
443
|
+
rollout_id=rollout_id,
|
|
444
|
+
docker_repository=docker_repository,
|
|
445
|
+
docker_image_tag=docker_image_tag,
|
|
446
|
+
)
|
|
447
|
+
except GitHubAPIError as e:
|
|
448
|
+
return ConnectorRolloutProgressResult(
|
|
449
|
+
success=False,
|
|
450
|
+
message=f"Failed to fetch approval comment from GitHub: {e}",
|
|
451
|
+
rollout_id=rollout_id,
|
|
452
|
+
docker_repository=docker_repository,
|
|
453
|
+
docker_image_tag=docker_image_tag,
|
|
454
|
+
)
|
|
455
|
+
except GitHubUserEmailNotFoundError as e:
|
|
456
|
+
return ConnectorRolloutProgressResult(
|
|
457
|
+
success=False,
|
|
458
|
+
message=str(e),
|
|
459
|
+
rollout_id=rollout_id,
|
|
460
|
+
docker_repository=docker_repository,
|
|
461
|
+
docker_image_tag=docker_image_tag,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Resolve auth credentials
|
|
465
|
+
try:
|
|
466
|
+
auth = _resolve_cloud_auth(ctx)
|
|
467
|
+
except CloudAuthError as e:
|
|
468
|
+
return ConnectorRolloutProgressResult(
|
|
469
|
+
success=False,
|
|
470
|
+
message=f"Failed to resolve credentials: {e}",
|
|
471
|
+
rollout_id=rollout_id,
|
|
472
|
+
docker_repository=docker_repository,
|
|
473
|
+
docker_image_tag=docker_image_tag,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Get user ID from admin email
|
|
477
|
+
try:
|
|
478
|
+
user_id = api_client.get_user_id_by_email(
|
|
479
|
+
email=admin_user_email,
|
|
480
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
481
|
+
client_id=auth.client_id,
|
|
482
|
+
client_secret=auth.client_secret,
|
|
483
|
+
bearer_token=auth.bearer_token,
|
|
484
|
+
)
|
|
485
|
+
except PyAirbyteInputError as e:
|
|
486
|
+
return ConnectorRolloutProgressResult(
|
|
487
|
+
success=False,
|
|
488
|
+
message=f"Failed to get user ID for admin email '{admin_user_email}': {e}",
|
|
489
|
+
rollout_id=rollout_id,
|
|
490
|
+
docker_repository=docker_repository,
|
|
491
|
+
docker_image_tag=docker_image_tag,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Call the API to progress the rollout
|
|
495
|
+
try:
|
|
496
|
+
api_client.progress_connector_rollout(
|
|
497
|
+
docker_repository=docker_repository,
|
|
498
|
+
docker_image_tag=docker_image_tag,
|
|
499
|
+
actor_definition_id=actor_definition_id,
|
|
500
|
+
rollout_id=rollout_id,
|
|
501
|
+
updated_by=user_id,
|
|
502
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
503
|
+
target_percentage=target_percentage,
|
|
504
|
+
actor_ids=actor_ids,
|
|
505
|
+
client_id=auth.client_id,
|
|
506
|
+
client_secret=auth.client_secret,
|
|
507
|
+
bearer_token=auth.bearer_token,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
progress_msg = (
|
|
511
|
+
f"target_percentage={target_percentage}%"
|
|
512
|
+
if target_percentage
|
|
513
|
+
else f"{len(actor_ids) if actor_ids else 0} specific actors"
|
|
514
|
+
)
|
|
515
|
+
return ConnectorRolloutProgressResult(
|
|
516
|
+
success=True,
|
|
517
|
+
message=f"Successfully progressed rollout for "
|
|
518
|
+
f"{docker_repository}:{docker_image_tag} to {progress_msg}.",
|
|
519
|
+
rollout_id=rollout_id,
|
|
520
|
+
docker_repository=docker_repository,
|
|
521
|
+
docker_image_tag=docker_image_tag,
|
|
522
|
+
target_percentage=target_percentage,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
except PyAirbyteInputError as e:
|
|
526
|
+
return ConnectorRolloutProgressResult(
|
|
527
|
+
success=False,
|
|
528
|
+
message=str(e),
|
|
529
|
+
rollout_id=rollout_id,
|
|
530
|
+
docker_repository=docker_repository,
|
|
531
|
+
docker_image_tag=docker_image_tag,
|
|
532
|
+
)
|
|
533
|
+
except Exception as e:
|
|
534
|
+
return ConnectorRolloutProgressResult(
|
|
535
|
+
success=False,
|
|
536
|
+
message=f"Failed to progress connector rollout: {e}",
|
|
537
|
+
rollout_id=rollout_id,
|
|
538
|
+
docker_repository=docker_repository,
|
|
539
|
+
docker_image_tag=docker_image_tag,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@mcp_tool(
|
|
544
|
+
destructive=True,
|
|
545
|
+
idempotent=False,
|
|
546
|
+
open_world=True,
|
|
547
|
+
)
|
|
548
|
+
def finalize_connector_rollout(
|
|
549
|
+
docker_repository: Annotated[
|
|
550
|
+
str,
|
|
551
|
+
Field(
|
|
552
|
+
description="The docker repository (e.g., 'airbyte/source-youtube-analytics')"
|
|
553
|
+
),
|
|
554
|
+
],
|
|
555
|
+
docker_image_tag: Annotated[
|
|
556
|
+
str,
|
|
557
|
+
Field(description="The docker image tag (e.g., '1.2.0-rc.2')"),
|
|
558
|
+
],
|
|
559
|
+
actor_definition_id: Annotated[
|
|
560
|
+
str,
|
|
561
|
+
Field(description="The actor definition ID (UUID)"),
|
|
562
|
+
],
|
|
563
|
+
rollout_id: Annotated[
|
|
564
|
+
str,
|
|
565
|
+
Field(
|
|
566
|
+
description="The rollout ID (UUID). Can be found in the 'pin_origin' field "
|
|
567
|
+
"of rollout data from query_prod_actors_by_connector_version."
|
|
568
|
+
),
|
|
569
|
+
],
|
|
570
|
+
state: Annotated[
|
|
571
|
+
Literal["succeeded", "failed_rolled_back", "canceled"],
|
|
572
|
+
Field(
|
|
573
|
+
description="The final state for the rollout: "
|
|
574
|
+
"'succeeded' promotes the RC to GA (default version for all users), "
|
|
575
|
+
"'failed_rolled_back' rolls back the RC, "
|
|
576
|
+
"'canceled' cancels the rollout without promotion or rollback."
|
|
577
|
+
),
|
|
578
|
+
],
|
|
579
|
+
approval_comment_url: Annotated[
|
|
580
|
+
str,
|
|
581
|
+
Field(
|
|
582
|
+
description="URL to a GitHub comment where an Airbyte contributor has "
|
|
583
|
+
"explicitly requested or authorized this rollout finalization. "
|
|
584
|
+
"Must be a valid GitHub comment URL (containing #issuecomment- or #discussion_r). "
|
|
585
|
+
"The admin email is automatically derived from the comment author's GitHub profile."
|
|
586
|
+
),
|
|
587
|
+
],
|
|
588
|
+
error_msg: Annotated[
|
|
589
|
+
str | None,
|
|
590
|
+
Field(
|
|
591
|
+
description="Optional error message for failed/canceled states.",
|
|
592
|
+
default=None,
|
|
593
|
+
),
|
|
594
|
+
] = None,
|
|
595
|
+
failed_reason: Annotated[
|
|
596
|
+
str | None,
|
|
597
|
+
Field(
|
|
598
|
+
description="Optional failure reason for failed/canceled states.",
|
|
599
|
+
default=None,
|
|
600
|
+
),
|
|
601
|
+
] = None,
|
|
602
|
+
retain_pins_on_cancellation: Annotated[
|
|
603
|
+
bool | None,
|
|
604
|
+
Field(
|
|
605
|
+
description="If True, retain version pins when canceling. "
|
|
606
|
+
"Only applicable when state is 'canceled'.",
|
|
607
|
+
default=None,
|
|
608
|
+
),
|
|
609
|
+
] = None,
|
|
610
|
+
*,
|
|
611
|
+
ctx: Context,
|
|
612
|
+
) -> ConnectorRolloutFinalizeResult:
|
|
613
|
+
"""Finalize a connector rollout by promoting, rolling back, or canceling.
|
|
614
|
+
|
|
615
|
+
This tool allows admins to finalize connector rollouts that are in progress.
|
|
616
|
+
Use this after monitoring a rollout and determining it is ready for finalization.
|
|
617
|
+
|
|
618
|
+
**Admin-only operation** - Requires:
|
|
619
|
+
- AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
|
|
620
|
+
- approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
|
|
621
|
+
|
|
622
|
+
The admin user email is automatically derived from the approval_comment_url by:
|
|
623
|
+
1. Fetching the comment from GitHub API to get the author's username
|
|
624
|
+
2. Fetching the user's profile to get their public email
|
|
625
|
+
3. Validating the email is an @airbyte.io address
|
|
626
|
+
"""
|
|
627
|
+
# Validate admin access (check env var flag)
|
|
628
|
+
try:
|
|
629
|
+
require_internal_admin_flag_only()
|
|
630
|
+
except CloudAuthError as e:
|
|
631
|
+
return ConnectorRolloutFinalizeResult(
|
|
632
|
+
success=False,
|
|
633
|
+
message=f"Admin authentication failed: {e}",
|
|
634
|
+
rollout_id=rollout_id,
|
|
635
|
+
docker_repository=docker_repository,
|
|
636
|
+
docker_image_tag=docker_image_tag,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Validate approval_comment_url format
|
|
640
|
+
if not approval_comment_url.startswith("https://github.com/"):
|
|
641
|
+
return ConnectorRolloutFinalizeResult(
|
|
642
|
+
success=False,
|
|
643
|
+
message=f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}",
|
|
644
|
+
rollout_id=rollout_id,
|
|
645
|
+
docker_repository=docker_repository,
|
|
646
|
+
docker_image_tag=docker_image_tag,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
if (
|
|
650
|
+
"#issuecomment-" not in approval_comment_url
|
|
651
|
+
and "#discussion_r" not in approval_comment_url
|
|
652
|
+
):
|
|
653
|
+
return ConnectorRolloutFinalizeResult(
|
|
654
|
+
success=False,
|
|
655
|
+
message="approval_comment_url must be a GitHub comment URL "
|
|
656
|
+
"(containing #issuecomment- or #discussion_r)",
|
|
657
|
+
rollout_id=rollout_id,
|
|
658
|
+
docker_repository=docker_repository,
|
|
659
|
+
docker_image_tag=docker_image_tag,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Derive admin email from approval comment URL
|
|
663
|
+
try:
|
|
664
|
+
admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
|
|
665
|
+
except GitHubCommentParseError as e:
|
|
666
|
+
return ConnectorRolloutFinalizeResult(
|
|
667
|
+
success=False,
|
|
668
|
+
message=f"Failed to parse approval comment URL: {e}",
|
|
669
|
+
rollout_id=rollout_id,
|
|
670
|
+
docker_repository=docker_repository,
|
|
671
|
+
docker_image_tag=docker_image_tag,
|
|
672
|
+
)
|
|
673
|
+
except GitHubAPIError as e:
|
|
674
|
+
return ConnectorRolloutFinalizeResult(
|
|
675
|
+
success=False,
|
|
676
|
+
message=f"Failed to fetch approval comment from GitHub: {e}",
|
|
677
|
+
rollout_id=rollout_id,
|
|
678
|
+
docker_repository=docker_repository,
|
|
679
|
+
docker_image_tag=docker_image_tag,
|
|
680
|
+
)
|
|
681
|
+
except GitHubUserEmailNotFoundError as e:
|
|
682
|
+
return ConnectorRolloutFinalizeResult(
|
|
683
|
+
success=False,
|
|
684
|
+
message=str(e),
|
|
685
|
+
rollout_id=rollout_id,
|
|
686
|
+
docker_repository=docker_repository,
|
|
687
|
+
docker_image_tag=docker_image_tag,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Resolve auth credentials
|
|
691
|
+
try:
|
|
692
|
+
auth = _resolve_cloud_auth(ctx)
|
|
693
|
+
except CloudAuthError as e:
|
|
694
|
+
return ConnectorRolloutFinalizeResult(
|
|
695
|
+
success=False,
|
|
696
|
+
message=f"Failed to resolve credentials: {e}",
|
|
697
|
+
rollout_id=rollout_id,
|
|
698
|
+
docker_repository=docker_repository,
|
|
699
|
+
docker_image_tag=docker_image_tag,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Get user ID from admin email
|
|
703
|
+
try:
|
|
704
|
+
user_id = api_client.get_user_id_by_email(
|
|
705
|
+
email=admin_user_email,
|
|
706
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
707
|
+
client_id=auth.client_id,
|
|
708
|
+
client_secret=auth.client_secret,
|
|
709
|
+
bearer_token=auth.bearer_token,
|
|
710
|
+
)
|
|
711
|
+
except PyAirbyteInputError as e:
|
|
712
|
+
return ConnectorRolloutFinalizeResult(
|
|
713
|
+
success=False,
|
|
714
|
+
message=f"Failed to get user ID for admin email '{admin_user_email}': {e}",
|
|
715
|
+
rollout_id=rollout_id,
|
|
716
|
+
docker_repository=docker_repository,
|
|
717
|
+
docker_image_tag=docker_image_tag,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Call the API to finalize the rollout
|
|
721
|
+
try:
|
|
722
|
+
api_client.finalize_connector_rollout(
|
|
723
|
+
docker_repository=docker_repository,
|
|
724
|
+
docker_image_tag=docker_image_tag,
|
|
725
|
+
actor_definition_id=actor_definition_id,
|
|
726
|
+
rollout_id=rollout_id,
|
|
727
|
+
updated_by=user_id,
|
|
728
|
+
state=state,
|
|
729
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
730
|
+
client_id=auth.client_id,
|
|
731
|
+
client_secret=auth.client_secret,
|
|
732
|
+
bearer_token=auth.bearer_token,
|
|
733
|
+
error_msg=error_msg,
|
|
734
|
+
failed_reason=failed_reason,
|
|
735
|
+
retain_pins_on_cancellation=retain_pins_on_cancellation,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
state_descriptions = {
|
|
739
|
+
"succeeded": "promoted to GA (default version for all users)",
|
|
740
|
+
"failed_rolled_back": "rolled back",
|
|
741
|
+
"canceled": "canceled",
|
|
742
|
+
}
|
|
743
|
+
state_desc = state_descriptions.get(state, state)
|
|
744
|
+
|
|
745
|
+
return ConnectorRolloutFinalizeResult(
|
|
746
|
+
success=True,
|
|
747
|
+
message=f"Successfully finalized rollout: {docker_repository}:{docker_image_tag} "
|
|
748
|
+
f"has been {state_desc}.",
|
|
749
|
+
rollout_id=rollout_id,
|
|
750
|
+
docker_repository=docker_repository,
|
|
751
|
+
docker_image_tag=docker_image_tag,
|
|
752
|
+
state=state,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
except PyAirbyteInputError as e:
|
|
756
|
+
return ConnectorRolloutFinalizeResult(
|
|
757
|
+
success=False,
|
|
758
|
+
message=str(e),
|
|
759
|
+
rollout_id=rollout_id,
|
|
760
|
+
docker_repository=docker_repository,
|
|
761
|
+
docker_image_tag=docker_image_tag,
|
|
762
|
+
)
|
|
763
|
+
except Exception as e:
|
|
764
|
+
return ConnectorRolloutFinalizeResult(
|
|
765
|
+
success=False,
|
|
766
|
+
message=f"Failed to finalize connector rollout: {e}",
|
|
767
|
+
rollout_id=rollout_id,
|
|
768
|
+
docker_repository=docker_repository,
|
|
769
|
+
docker_image_tag=docker_image_tag,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class RolloutActorSelectionInfo(BaseModel):
|
|
774
|
+
"""Actor selection info for a connector rollout."""
|
|
775
|
+
|
|
776
|
+
num_actors: int = Field(description="Total actors using this connector")
|
|
777
|
+
num_pinned_to_connector_rollout: int = Field(
|
|
778
|
+
description="Actors specifically pinned to this rollout"
|
|
779
|
+
)
|
|
780
|
+
num_actors_eligible_or_already_pinned: int = Field(
|
|
781
|
+
description="Actors eligible for pinning or already pinned"
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
class RolloutActorSyncStats(BaseModel):
|
|
786
|
+
"""Per-actor sync stats for a rollout (only syncs using the RC version)."""
|
|
787
|
+
|
|
788
|
+
actor_id: str = Field(description="Actor UUID")
|
|
789
|
+
num_connections: int = Field(description="Number of connections using this actor")
|
|
790
|
+
num_succeeded: int = Field(
|
|
791
|
+
description="Number of successful syncs using the RC version"
|
|
792
|
+
)
|
|
793
|
+
num_failed: int = Field(description="Number of failed syncs using the RC version")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
class RolloutMonitoringResult(BaseModel):
|
|
797
|
+
"""Complete monitoring result for a rollout from the platform API.
|
|
798
|
+
|
|
799
|
+
This uses the platform API's /get_actor_sync_info endpoint which filters
|
|
800
|
+
sync stats to only include syncs that actually used the RC version
|
|
801
|
+
associated with the rollout.
|
|
802
|
+
"""
|
|
803
|
+
|
|
804
|
+
rollout_id: str = Field(description="Rollout UUID")
|
|
805
|
+
actor_selection_info: RolloutActorSelectionInfo = Field(
|
|
806
|
+
description="Actor selection info for the rollout"
|
|
807
|
+
)
|
|
808
|
+
actor_sync_stats: list[RolloutActorSyncStats] = Field(
|
|
809
|
+
description="Per-actor sync stats for actors pinned to the rollout"
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@mcp_tool(
|
|
814
|
+
read_only=True,
|
|
815
|
+
idempotent=True,
|
|
816
|
+
)
|
|
817
|
+
def query_prod_rollout_monitoring_stats(
|
|
818
|
+
rollout_id: Annotated[
|
|
819
|
+
str,
|
|
820
|
+
Field(description="Rollout UUID to get monitoring stats for"),
|
|
821
|
+
],
|
|
822
|
+
*,
|
|
823
|
+
ctx: Context,
|
|
824
|
+
) -> RolloutMonitoringResult:
|
|
825
|
+
"""Get monitoring stats for a connector rollout.
|
|
826
|
+
|
|
827
|
+
Returns actor selection info and per-actor sync stats for actors
|
|
828
|
+
participating in the rollout. This uses the platform API's
|
|
829
|
+
/get_actor_sync_info endpoint which filters sync stats to only include
|
|
830
|
+
syncs that actually used the RC version associated with the rollout.
|
|
831
|
+
|
|
832
|
+
This is more accurate than SQL-based approaches which count all syncs
|
|
833
|
+
regardless of which connector version was used.
|
|
834
|
+
"""
|
|
835
|
+
auth = _resolve_cloud_auth(ctx)
|
|
836
|
+
|
|
837
|
+
response = api_client.get_actor_sync_info(
|
|
838
|
+
rollout_id=rollout_id,
|
|
839
|
+
config_api_root=constants.CLOUD_CONFIG_API_ROOT,
|
|
840
|
+
client_id=auth.client_id,
|
|
841
|
+
client_secret=auth.client_secret,
|
|
842
|
+
bearer_token=auth.bearer_token,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
data = response.get("data", {})
|
|
846
|
+
actor_selection_info_data = data.get("actorSelectionInfo", {})
|
|
847
|
+
syncs_data = data.get("syncs", {})
|
|
848
|
+
|
|
849
|
+
actor_selection_info = RolloutActorSelectionInfo(
|
|
850
|
+
num_actors=actor_selection_info_data.get("numActors", 0),
|
|
851
|
+
num_pinned_to_connector_rollout=actor_selection_info_data.get(
|
|
852
|
+
"numPinnedToConnectorRollout", 0
|
|
853
|
+
),
|
|
854
|
+
num_actors_eligible_or_already_pinned=actor_selection_info_data.get(
|
|
855
|
+
"numActorsEligibleOrAlreadyPinned", 0
|
|
856
|
+
),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
actor_sync_stats = [
|
|
860
|
+
RolloutActorSyncStats(
|
|
861
|
+
actor_id=actor_id,
|
|
862
|
+
num_connections=sync_info.get("numConnections", 0),
|
|
863
|
+
num_succeeded=sync_info.get("numSucceeded", 0),
|
|
864
|
+
num_failed=sync_info.get("numFailed", 0),
|
|
865
|
+
)
|
|
866
|
+
for actor_id, sync_info in syncs_data.items()
|
|
867
|
+
]
|
|
868
|
+
|
|
869
|
+
return RolloutMonitoringResult(
|
|
870
|
+
rollout_id=rollout_id,
|
|
871
|
+
actor_selection_info=actor_selection_info,
|
|
872
|
+
actor_sync_stats=actor_sync_stats,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def register_connector_rollout_tools(app: FastMCP) -> None:
|
|
877
|
+
"""Register connector rollout tools with the FastMCP app.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
app: FastMCP application instance
|
|
881
|
+
"""
|
|
882
|
+
register_mcp_tools(app)
|