airbyte-internal-ops 0.2.0__py3-none-any.whl → 0.2.2__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.
Files changed (41) hide show
  1. {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/METADATA +19 -3
  2. {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/RECORD +41 -41
  3. airbyte_ops_mcp/__init__.py +2 -2
  4. airbyte_ops_mcp/cli/cloud.py +207 -306
  5. airbyte_ops_mcp/cloud_admin/api_client.py +51 -26
  6. airbyte_ops_mcp/cloud_admin/connection_config.py +2 -2
  7. airbyte_ops_mcp/constants.py +61 -1
  8. airbyte_ops_mcp/github_actions.py +69 -1
  9. airbyte_ops_mcp/mcp/_http_headers.py +56 -0
  10. airbyte_ops_mcp/mcp/_mcp_utils.py +2 -2
  11. airbyte_ops_mcp/mcp/cloud_connector_versions.py +57 -43
  12. airbyte_ops_mcp/mcp/github.py +34 -1
  13. airbyte_ops_mcp/mcp/prerelease.py +3 -3
  14. airbyte_ops_mcp/mcp/prod_db_queries.py +293 -50
  15. airbyte_ops_mcp/mcp/{live_tests.py → regression_tests.py} +158 -176
  16. airbyte_ops_mcp/mcp/server.py +3 -3
  17. airbyte_ops_mcp/prod_db_access/db_engine.py +7 -11
  18. airbyte_ops_mcp/prod_db_access/queries.py +79 -0
  19. airbyte_ops_mcp/prod_db_access/sql.py +86 -0
  20. airbyte_ops_mcp/{live_tests → regression_tests}/__init__.py +3 -3
  21. airbyte_ops_mcp/{live_tests → regression_tests}/cdk_secrets.py +1 -1
  22. airbyte_ops_mcp/{live_tests → regression_tests}/connection_secret_retriever.py +3 -3
  23. airbyte_ops_mcp/{live_tests → regression_tests}/connector_runner.py +1 -1
  24. airbyte_ops_mcp/{live_tests → regression_tests}/message_cache/__init__.py +3 -1
  25. airbyte_ops_mcp/{live_tests → regression_tests}/regression/__init__.py +1 -1
  26. airbyte_ops_mcp/{live_tests → regression_tests}/schema_generation.py +3 -1
  27. airbyte_ops_mcp/{live_tests → regression_tests}/validation/__init__.py +2 -2
  28. airbyte_ops_mcp/{live_tests → regression_tests}/validation/record_validators.py +4 -2
  29. {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/WHEEL +0 -0
  30. {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/entry_points.txt +0 -0
  31. /airbyte_ops_mcp/{live_tests → regression_tests}/ci_output.py +0 -0
  32. /airbyte_ops_mcp/{live_tests → regression_tests}/commons/__init__.py +0 -0
  33. /airbyte_ops_mcp/{live_tests → regression_tests}/config.py +0 -0
  34. /airbyte_ops_mcp/{live_tests → regression_tests}/connection_fetcher.py +0 -0
  35. /airbyte_ops_mcp/{live_tests → regression_tests}/evaluation_modes.py +0 -0
  36. /airbyte_ops_mcp/{live_tests → regression_tests}/http_metrics.py +0 -0
  37. /airbyte_ops_mcp/{live_tests → regression_tests}/message_cache/duckdb_cache.py +0 -0
  38. /airbyte_ops_mcp/{live_tests → regression_tests}/models.py +0 -0
  39. /airbyte_ops_mcp/{live_tests → regression_tests}/obfuscation.py +0 -0
  40. /airbyte_ops_mcp/{live_tests → regression_tests}/regression/comparators.py +0 -0
  41. /airbyte_ops_mcp/{live_tests → regression_tests}/validation/catalog_validators.py +0 -0
@@ -14,12 +14,27 @@ import requests
14
14
  from fastmcp import FastMCP
15
15
  from pydantic import BaseModel, Field
16
16
 
17
- from airbyte_ops_mcp.github_actions import GITHUB_API_BASE, resolve_github_token
17
+ from airbyte_ops_mcp.github_actions import (
18
+ GITHUB_API_BASE,
19
+ get_workflow_jobs,
20
+ resolve_github_token,
21
+ )
18
22
  from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
19
23
 
20
24
  DOCKERHUB_API_BASE = "https://hub.docker.com/v2"
21
25
 
22
26
 
27
+ class JobInfo(BaseModel):
28
+ """Information about a single job in a workflow run."""
29
+
30
+ job_id: int
31
+ name: str
32
+ status: str
33
+ conclusion: str | None = None
34
+ started_at: str | None = None
35
+ completed_at: str | None = None
36
+
37
+
23
38
  class WorkflowRunStatus(BaseModel):
24
39
  """Response model for check_workflow_status MCP tool."""
25
40
 
@@ -34,6 +49,7 @@ class WorkflowRunStatus(BaseModel):
34
49
  updated_at: str
35
50
  run_started_at: str | None = None
36
51
  jobs_url: str
52
+ jobs: list[JobInfo] = []
37
53
 
38
54
 
39
55
  def _parse_workflow_url(url: str) -> tuple[str, str, int]:
@@ -148,6 +164,22 @@ def check_workflow_status(
148
164
  # Get workflow run details
149
165
  run_data = _get_workflow_run(owner, repo, run_id, token)
150
166
 
167
+ # Get jobs for the workflow run (uses upstream function that resolves its own token)
168
+ workflow_jobs = get_workflow_jobs(owner, repo, run_id)
169
+
170
+ # Convert dataclass objects to Pydantic models for the response
171
+ jobs = [
172
+ JobInfo(
173
+ job_id=job.job_id,
174
+ name=job.name,
175
+ status=job.status,
176
+ conclusion=job.conclusion,
177
+ started_at=job.started_at,
178
+ completed_at=job.completed_at,
179
+ )
180
+ for job in workflow_jobs
181
+ ]
182
+
151
183
  return WorkflowRunStatus(
152
184
  run_id=run_data["id"],
153
185
  status=run_data["status"],
@@ -160,6 +192,7 @@ def check_workflow_status(
160
192
  updated_at=run_data["updated_at"],
161
193
  run_started_at=run_data.get("run_started_at"),
162
194
  jobs_url=run_data["jobs_url"],
195
+ jobs=jobs,
163
196
  )
164
197
 
165
198
 
@@ -228,17 +228,17 @@ def publish_connector_to_airbyte_registry(
228
228
  # Guard: Check for required token
229
229
  token = resolve_github_token(PRERELEASE_TOKEN_ENV_VARS)
230
230
 
231
- # Get the PR's head ref and SHA
231
+ # Get the PR's head SHA for computing the docker image tag
232
+ # Note: We no longer pass gitref to the workflow - it derives the ref from PR number
232
233
  head_info = _get_pr_head_info(
233
234
  DEFAULT_REPO_OWNER, DEFAULT_REPO_NAME, pr_number, token
234
235
  )
235
236
 
236
237
  # Prepare workflow inputs
237
- # The workflow expects these inputs from slash-command-dispatch
238
+ # The workflow uses refs/pull/{pr}/head directly - no gitref needed
238
239
  # Note: The workflow auto-detects modified connectors from the PR
239
240
  workflow_inputs = {
240
241
  "repo": f"{DEFAULT_REPO_OWNER}/{DEFAULT_REPO_NAME}",
241
- "gitref": head_info.ref,
242
242
  "pr": str(pr_number),
243
243
  }
244
244
 
@@ -7,28 +7,78 @@ airbyte_ops_mcp.prod_db_access.queries for use by AI agents.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from datetime import datetime
10
11
  from typing import Annotated, Any
11
12
 
12
13
  import requests
13
14
  from airbyte.exceptions import PyAirbyteInputError
14
15
  from fastmcp import FastMCP
15
- from pydantic import Field
16
+ from pydantic import BaseModel, Field
16
17
 
18
+ from airbyte_ops_mcp.constants import OrganizationAliasEnum
17
19
  from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
18
20
  from airbyte_ops_mcp.prod_db_access.queries import (
19
21
  query_actors_pinned_to_version,
20
22
  query_connections_by_connector,
23
+ query_connections_by_destination_connector,
21
24
  query_connector_versions,
22
25
  query_dataplanes_list,
23
26
  query_failed_sync_attempts_for_connector,
24
27
  query_new_connector_releases,
25
28
  query_sync_results_for_version,
26
29
  query_workspace_info,
30
+ query_workspaces_by_email_domain,
27
31
  )
28
32
 
29
33
  # Cloud UI base URL for building connection URLs
30
34
  CLOUD_UI_BASE_URL = "https://cloud.airbyte.com"
31
35
 
36
+
37
+ # =============================================================================
38
+ # Pydantic Models for MCP Tool Responses
39
+ # =============================================================================
40
+
41
+
42
+ class WorkspaceInfo(BaseModel):
43
+ """Information about a workspace found by email domain search."""
44
+
45
+ organization_id: str = Field(description="The organization UUID")
46
+ workspace_id: str = Field(description="The workspace UUID")
47
+ workspace_name: str = Field(description="The name of the workspace")
48
+ slug: str | None = Field(
49
+ default=None, description="The workspace slug (URL-friendly identifier)"
50
+ )
51
+ email: str | None = Field(
52
+ default=None, description="The email address associated with the workspace"
53
+ )
54
+ dataplane_group_id: str | None = Field(
55
+ default=None, description="The dataplane group UUID (region)"
56
+ )
57
+ dataplane_name: str | None = Field(
58
+ default=None, description="The name of the dataplane (e.g., 'US', 'EU')"
59
+ )
60
+ created_at: datetime | None = Field(
61
+ default=None, description="When the workspace was created"
62
+ )
63
+
64
+
65
+ class WorkspacesByEmailDomainResult(BaseModel):
66
+ """Result of looking up workspaces by email domain."""
67
+
68
+ email_domain: str = Field(
69
+ description="The email domain that was searched for (e.g., 'motherduck.com')"
70
+ )
71
+ total_workspaces_found: int = Field(
72
+ description="Total number of workspaces matching the email domain"
73
+ )
74
+ unique_organization_ids: list[str] = Field(
75
+ description="List of unique organization IDs found"
76
+ )
77
+ workspaces: list[WorkspaceInfo] = Field(
78
+ description="List of workspaces matching the email domain"
79
+ )
80
+
81
+
32
82
  # Cloud registry URL for resolving canonical names
33
83
  CLOUD_REGISTRY_URL = (
34
84
  "https://connectors.airbyte.com/files/registries/v0/cloud_registry.json"
@@ -36,13 +86,18 @@ CLOUD_REGISTRY_URL = (
36
86
 
37
87
 
38
88
  def _resolve_canonical_name_to_definition_id(canonical_name: str) -> str:
39
- """Resolve a canonical source name to a definition ID.
89
+ """Resolve a canonical connector name to a definition ID.
90
+
91
+ Auto-detects whether the connector is a source or destination based on the
92
+ canonical name prefix ("source-" or "destination-"). If no prefix is present,
93
+ searches both sources and destinations.
40
94
 
41
95
  Args:
42
- canonical_name: Canonical source name (e.g., 'source-youtube-analytics').
96
+ canonical_name: Canonical connector name (e.g., 'source-youtube-analytics',
97
+ 'destination-duckdb', 'YouTube Analytics', 'DuckDB').
43
98
 
44
99
  Returns:
45
- The source definition ID (UUID).
100
+ The connector definition ID (UUID).
46
101
 
47
102
  Raises:
48
103
  PyAirbyteInputError: If the canonical name cannot be resolved.
@@ -56,31 +111,65 @@ def _resolve_canonical_name_to_definition_id(canonical_name: str) -> str:
56
111
  )
57
112
 
58
113
  data = response.json()
59
- sources = data.get("sources", [])
60
-
61
- # Normalize the canonical name for matching
62
114
  normalized_input = canonical_name.lower().strip()
63
115
 
64
- # Try exact match on name field
65
- for source in sources:
66
- source_name = source.get("name", "").lower()
67
- # The registry returns names like "YouTube Analytics"
68
- # So we need to handle both formats
69
- if source_name == normalized_input:
70
- return source["sourceDefinitionId"]
71
-
72
- # Also try matching against a slugified version
73
- # e.g., "YouTube Analytics" -> "youtube-analytics"
74
- slugified = source_name.replace(" ", "-")
75
- if slugified == normalized_input or f"source-{slugified}" == normalized_input:
76
- return source["sourceDefinitionId"]
116
+ # Determine which registries to search based on prefix
117
+ is_source = normalized_input.startswith("source-")
118
+ is_destination = normalized_input.startswith("destination-")
119
+
120
+ # Search sources if it looks like a source or has no prefix
121
+ if is_source or not is_destination:
122
+ sources = data.get("sources", [])
123
+ for source in sources:
124
+ source_name = source.get("name", "").lower()
125
+ if source_name == normalized_input:
126
+ return source["sourceDefinitionId"]
127
+ slugified = source_name.replace(" ", "-")
128
+ if (
129
+ slugified == normalized_input
130
+ or f"source-{slugified}" == normalized_input
131
+ ):
132
+ return source["sourceDefinitionId"]
133
+
134
+ # Search destinations if it looks like a destination or has no prefix
135
+ if is_destination or not is_source:
136
+ destinations = data.get("destinations", [])
137
+ for destination in destinations:
138
+ destination_name = destination.get("name", "").lower()
139
+ if destination_name == normalized_input:
140
+ return destination["destinationDefinitionId"]
141
+ slugified = destination_name.replace(" ", "-")
142
+ if (
143
+ slugified == normalized_input
144
+ or f"destination-{slugified}" == normalized_input
145
+ ):
146
+ return destination["destinationDefinitionId"]
147
+
148
+ # Build appropriate error message based on what was searched
149
+ if is_source:
150
+ connector_type = "source"
151
+ hint = (
152
+ "Use the exact canonical name (e.g., 'source-youtube-analytics') "
153
+ "or display name (e.g., 'YouTube Analytics')."
154
+ )
155
+ elif is_destination:
156
+ connector_type = "destination"
157
+ hint = (
158
+ "Use the exact canonical name (e.g., 'destination-duckdb') "
159
+ "or display name (e.g., 'DuckDB')."
160
+ )
161
+ else:
162
+ connector_type = "connector"
163
+ hint = (
164
+ "Use the exact canonical name (e.g., 'source-youtube-analytics', "
165
+ "'destination-duckdb') or display name (e.g., 'YouTube Analytics', 'DuckDB')."
166
+ )
77
167
 
78
168
  raise PyAirbyteInputError(
79
- message=f"Could not find source definition for canonical name: {canonical_name}",
169
+ message=f"Could not find {connector_type} definition for canonical name: {canonical_name}",
80
170
  context={
81
- "hint": "Use the exact canonical name (e.g., 'source-youtube-analytics') "
82
- "or display name (e.g., 'YouTube Analytics'). "
83
- "You can list available sources using the connector registry tools.",
171
+ "hint": hint
172
+ + " You can list available connectors using the connector registry tools.",
84
173
  "searched_for": canonical_name,
85
174
  },
86
175
  )
@@ -275,11 +364,12 @@ def query_prod_failed_sync_attempts_for_connector(
275
364
  ),
276
365
  ] = None,
277
366
  organization_id: Annotated[
278
- str | None,
367
+ str | OrganizationAliasEnum | None,
279
368
  Field(
280
369
  description=(
281
- "Optional organization ID (UUID) to filter results. "
282
- "If provided, only failed attempts from this organization will be returned."
370
+ "Optional organization ID (UUID) or alias to filter results. "
371
+ "If provided, only failed attempts from this organization will be returned. "
372
+ "Accepts '@airbyte-internal' as an alias for the Airbyte internal org."
283
373
  ),
284
374
  default=None,
285
375
  ),
@@ -327,9 +417,12 @@ def query_prod_failed_sync_attempts_for_connector(
327
417
  else:
328
418
  resolved_definition_id = source_definition_id # type: ignore[assignment]
329
419
 
420
+ # Resolve organization ID alias
421
+ resolved_organization_id = OrganizationAliasEnum.resolve(organization_id)
422
+
330
423
  return query_failed_sync_attempts_for_connector(
331
424
  connector_definition_id=resolved_definition_id,
332
- organization_id=organization_id,
425
+ organization_id=resolved_organization_id,
333
426
  days=days,
334
427
  limit=limit,
335
428
  )
@@ -346,7 +439,8 @@ def query_prod_connections_by_connector(
346
439
  Field(
347
440
  description=(
348
441
  "Source connector definition ID (UUID) to search for. "
349
- "Exactly one of this or source_canonical_name is required. "
442
+ "Exactly one of source_definition_id, source_canonical_name, "
443
+ "destination_definition_id, or destination_canonical_name is required. "
350
444
  "Example: 'afa734e4-3571-11ec-991a-1e0031268139' for YouTube Analytics."
351
445
  ),
352
446
  default=None,
@@ -357,18 +451,44 @@ def query_prod_connections_by_connector(
357
451
  Field(
358
452
  description=(
359
453
  "Canonical source connector name to search for. "
360
- "Exactly one of this or source_definition_id is required. "
454
+ "Exactly one of source_definition_id, source_canonical_name, "
455
+ "destination_definition_id, or destination_canonical_name is required. "
361
456
  "Examples: 'source-youtube-analytics', 'YouTube Analytics'."
362
457
  ),
363
458
  default=None,
364
459
  ),
365
460
  ] = None,
366
- organization_id: Annotated[
461
+ destination_definition_id: Annotated[
367
462
  str | None,
368
463
  Field(
369
464
  description=(
370
- "Optional organization ID (UUID) to filter results. "
371
- "If provided, only connections in this organization will be returned."
465
+ "Destination connector definition ID (UUID) to search for. "
466
+ "Exactly one of source_definition_id, source_canonical_name, "
467
+ "destination_definition_id, or destination_canonical_name is required. "
468
+ "Example: 'e5c8e66c-a480-4a5e-9c0e-e8e5e4c5c5c5' for DuckDB."
469
+ ),
470
+ default=None,
471
+ ),
472
+ ] = None,
473
+ destination_canonical_name: Annotated[
474
+ str | None,
475
+ Field(
476
+ description=(
477
+ "Canonical destination connector name to search for. "
478
+ "Exactly one of source_definition_id, source_canonical_name, "
479
+ "destination_definition_id, or destination_canonical_name is required. "
480
+ "Examples: 'destination-duckdb', 'DuckDB'."
481
+ ),
482
+ default=None,
483
+ ),
484
+ ] = None,
485
+ organization_id: Annotated[
486
+ str | OrganizationAliasEnum | None,
487
+ Field(
488
+ description=(
489
+ "Optional organization ID (UUID) or alias to filter results. "
490
+ "If provided, only connections in this organization will be returned. "
491
+ "Accepts '@airbyte-internal' as an alias for the Airbyte internal org."
372
492
  ),
373
493
  default=None,
374
494
  ),
@@ -378,38 +498,88 @@ def query_prod_connections_by_connector(
378
498
  Field(description="Maximum number of results (default: 1000)", default=1000),
379
499
  ] = 1000,
380
500
  ) -> list[dict[str, Any]]:
381
- """Search for all connections using a specific source connector type.
501
+ """Search for all connections using a specific source or destination connector type.
382
502
 
383
503
  This tool queries the Airbyte Cloud Prod DB Replica directly for fast results.
384
- It finds all connections where the source connector matches the specified type,
385
- regardless of how the source is named by users.
504
+ It finds all connections where the source or destination connector matches the
505
+ specified type, regardless of how the connector is named by users.
386
506
 
387
507
  Optionally filter by organization_id to limit results to a specific organization.
508
+ Use '@airbyte-internal' as an alias for the Airbyte internal organization.
388
509
 
389
510
  Returns a list of connection dicts with workspace context and clickable Cloud UI URLs.
390
- Each dict contains: connection_id, connection_name, connection_url, source_id,
511
+ For source queries, returns: connection_id, connection_name, connection_url, source_id,
391
512
  source_name, source_definition_id, workspace_id, workspace_name, organization_id,
392
513
  dataplane_group_id, dataplane_name.
514
+ For destination queries, returns: connection_id, connection_name, connection_url,
515
+ destination_id, destination_name, destination_definition_id, workspace_id,
516
+ workspace_name, organization_id, dataplane_group_id, dataplane_name.
393
517
  """
394
- # Validate that exactly one of the two parameters is provided
395
- if (source_definition_id is None) == (source_canonical_name is None):
518
+ # Validate that exactly one of the four connector parameters is provided
519
+ provided_params = [
520
+ source_definition_id,
521
+ source_canonical_name,
522
+ destination_definition_id,
523
+ destination_canonical_name,
524
+ ]
525
+ num_provided = sum(p is not None for p in provided_params)
526
+ if num_provided != 1:
396
527
  raise PyAirbyteInputError(
397
528
  message=(
398
- "Exactly one of source_definition_id or source_canonical_name "
399
- "must be provided, but not both."
529
+ "Exactly one of source_definition_id, source_canonical_name, "
530
+ "destination_definition_id, or destination_canonical_name must be provided."
400
531
  ),
401
532
  )
402
533
 
403
- # Resolve canonical name to definition ID if needed
534
+ # Determine if this is a source or destination query and resolve the definition ID
535
+ is_source_query = (
536
+ source_definition_id is not None or source_canonical_name is not None
537
+ )
404
538
  resolved_definition_id: str
539
+
405
540
  if source_canonical_name:
406
541
  resolved_definition_id = _resolve_canonical_name_to_definition_id(
407
542
  canonical_name=source_canonical_name,
408
543
  )
544
+ elif source_definition_id:
545
+ resolved_definition_id = source_definition_id
546
+ elif destination_canonical_name:
547
+ resolved_definition_id = _resolve_canonical_name_to_definition_id(
548
+ canonical_name=destination_canonical_name,
549
+ )
409
550
  else:
410
- resolved_definition_id = source_definition_id # type: ignore[assignment]
411
-
412
- # Query the database and transform rows to include connection URLs
551
+ resolved_definition_id = destination_definition_id # type: ignore[assignment]
552
+
553
+ # Resolve organization ID alias
554
+ resolved_organization_id = OrganizationAliasEnum.resolve(organization_id)
555
+
556
+ # Query the database based on connector type
557
+ if is_source_query:
558
+ return [
559
+ {
560
+ "organization_id": str(row.get("organization_id", "")),
561
+ "workspace_id": str(row["workspace_id"]),
562
+ "workspace_name": row.get("workspace_name", ""),
563
+ "connection_id": str(row["connection_id"]),
564
+ "connection_name": row.get("connection_name", ""),
565
+ "connection_url": (
566
+ f"{CLOUD_UI_BASE_URL}/workspaces/{row['workspace_id']}"
567
+ f"/connections/{row['connection_id']}/status"
568
+ ),
569
+ "source_id": str(row["source_id"]),
570
+ "source_name": row.get("source_name", ""),
571
+ "source_definition_id": str(row["source_definition_id"]),
572
+ "dataplane_group_id": str(row.get("dataplane_group_id", "")),
573
+ "dataplane_name": row.get("dataplane_name", ""),
574
+ }
575
+ for row in query_connections_by_connector(
576
+ connector_definition_id=resolved_definition_id,
577
+ organization_id=resolved_organization_id,
578
+ limit=limit,
579
+ )
580
+ ]
581
+
582
+ # Destination query
413
583
  return [
414
584
  {
415
585
  "organization_id": str(row.get("organization_id", "")),
@@ -421,20 +591,93 @@ def query_prod_connections_by_connector(
421
591
  f"{CLOUD_UI_BASE_URL}/workspaces/{row['workspace_id']}"
422
592
  f"/connections/{row['connection_id']}/status"
423
593
  ),
424
- "source_id": str(row["source_id"]),
425
- "source_name": row.get("source_name", ""),
426
- "source_definition_id": str(row["source_definition_id"]),
594
+ "destination_id": str(row["destination_id"]),
595
+ "destination_name": row.get("destination_name", ""),
596
+ "destination_definition_id": str(row["destination_definition_id"]),
427
597
  "dataplane_group_id": str(row.get("dataplane_group_id", "")),
428
598
  "dataplane_name": row.get("dataplane_name", ""),
429
599
  }
430
- for row in query_connections_by_connector(
600
+ for row in query_connections_by_destination_connector(
431
601
  connector_definition_id=resolved_definition_id,
432
- organization_id=organization_id,
602
+ organization_id=resolved_organization_id,
433
603
  limit=limit,
434
604
  )
435
605
  ]
436
606
 
437
607
 
608
+ @mcp_tool(
609
+ read_only=True,
610
+ idempotent=True,
611
+ )
612
+ def query_prod_workspaces_by_email_domain(
613
+ email_domain: Annotated[
614
+ str,
615
+ Field(
616
+ description=(
617
+ "Email domain to search for (e.g., 'motherduck.com', 'fivetran.com'). "
618
+ "Do not include the '@' symbol. This will find workspaces where users "
619
+ "have email addresses with this domain."
620
+ ),
621
+ ),
622
+ ],
623
+ limit: Annotated[
624
+ int,
625
+ Field(
626
+ description="Maximum number of workspaces to return (default: 100)",
627
+ default=100,
628
+ ),
629
+ ] = 100,
630
+ ) -> WorkspacesByEmailDomainResult:
631
+ """Find workspaces by email domain.
632
+
633
+ This tool searches for workspaces where users have email addresses matching
634
+ the specified domain. This is useful for identifying workspaces belonging to
635
+ specific companies - for example, searching for "motherduck.com" will find
636
+ workspaces belonging to MotherDuck employees.
637
+
638
+ Use cases:
639
+ - Finding partner organization connections for testing connector fixes
640
+ - Identifying internal test accounts for specific integrations
641
+ - Locating workspaces belonging to technology partners
642
+
643
+ The returned organization IDs can be used with other tools like
644
+ `query_prod_connections_by_connector` to find connections within
645
+ those organizations for safe testing.
646
+ """
647
+ # Strip leading @ if provided
648
+ clean_domain = email_domain.lstrip("@")
649
+
650
+ # Query the database
651
+ rows = query_workspaces_by_email_domain(email_domain=clean_domain, limit=limit)
652
+
653
+ # Convert rows to Pydantic models
654
+ workspaces = [
655
+ WorkspaceInfo(
656
+ organization_id=str(row["organization_id"]),
657
+ workspace_id=str(row["workspace_id"]),
658
+ workspace_name=row.get("workspace_name", ""),
659
+ slug=row.get("slug"),
660
+ email=row.get("email"),
661
+ dataplane_group_id=str(row["dataplane_group_id"])
662
+ if row.get("dataplane_group_id")
663
+ else None,
664
+ dataplane_name=row.get("dataplane_name"),
665
+ created_at=row.get("created_at"),
666
+ )
667
+ for row in rows
668
+ ]
669
+
670
+ # Extract unique organization IDs
671
+ unique_org_ids = list(dict.fromkeys(w.organization_id for w in workspaces))
672
+
673
+ return WorkspacesByEmailDomainResult(
674
+ email_domain=clean_domain,
675
+ total_workspaces_found=len(workspaces),
676
+ unique_organization_ids=unique_org_ids,
677
+ workspaces=workspaces,
678
+ )
679
+
680
+
438
681
  def register_prod_db_query_tools(app: FastMCP) -> None:
439
682
  """Register prod DB query tools with the FastMCP app."""
440
683
  register_mcp_tools(app, domain=__name__)