airbyte_ops_mcp.mcp.organization_payment_config

MCP tools for organization payment config management.

Provides tools to read and update organization payment configurations in Airbyte Cloud, including grace period management and payment status changes.

MCP reference

MCP primitives registered by the organization_payment_config module of the airbyte-internal-ops server: 2 tool(s), 0 prompt(s), 0 resource(s).

Tools (2)

get_organization_payment_config

Hints: read-only · idempotent · open-world

Get the current payment configuration for an organization.

Returns payment status, subscription status, grace period info, and usage category override. No PII or sensitive payment details are included in the response.

Authentication credentials are resolved in priority order:

  1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
  2. HTTP headers: X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret
  3. Environment variables: AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET

Parameters:

Name Type Required Default Description
organization_id string yes The organization UUID.
config_api_root string | null no null Optional Config API root URL override. Defaults to Airbyte Cloud (https://cloud.airbyte.com/api/v1).

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "organization_id": {
      "description": "The organization UUID.",
      "type": "string"
    },
    "config_api_root": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional Config API root URL override. Defaults to Airbyte Cloud (`https://cloud.airbyte.com/api/v1`)."
    }
  },
  "required": [
    "organization_id"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Current payment configuration for an organization.\n\nReturned by the `GET /api/v1/organization_payment_config/{organizationId}` endpoint.",
  "properties": {
    "organization_id": {
      "description": "The organization UUID",
      "type": "string"
    },
    "payment_status": {
      "description": "Payment status: `uninitialized`, `okay`, `grace_period`, `disabled`, `locked`, or `manual`",
      "type": "string"
    },
    "subscription_status": {
      "description": "Subscription status: `pre_subscription`, `subscribed`, or `unsubscribed`",
      "type": "string"
    },
    "payment_provider_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "External payment provider ID (e.g. Stripe customer ID)"
    },
    "grace_period_end_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ISO 8601 datetime when the grace period ends (if active)"
    },
    "usage_category_overwrite": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Usage category override: `free` or `internal` (if set)"
    },
    "customer_tier": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Customer tier of the organization (TIER_0, TIER_1, TIER_2)"
    },
    "tier_warning": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Warning message if the organization is a sensitive customer tier"
    }
  },
  "required": [
    "organization_id",
    "payment_status",
    "subscription_status"
  ],
  "type": "object"
}

update_organization_payment_config

Hints: destructive · open-world

Update the payment configuration for an organization.

All updates require human-in-the-loop approval via escalate_to_human.

Use set_grace_period to start, extend, or cancel a grace period. If the org is not already in manual status, the tool automatically transitions to manual first before setting the grace period.

The organization_name parameter is a safety check: the tool looks up the organization via the Config API and verifies that the provided name, email, or email domain matches. If omitted or mismatched, the tool returns the valid identifiers so the caller can verify and retry.

Parameters:

Name Type Required Default Description
organization_id string yes The organization UUID.
approval_comment_url string yes URL to the Slack approval record. Obtain this by calling escalate_to_human with approval_requested=True; the backend delivers the approval record URL when a human clicks Approve.
organization_name string | null no null Confirmation of the target organization. Accepts the organization name, email address, or email domain. Required to prevent accidental modifications to the wrong organization. If omitted or mismatched, the tool returns an error with the valid identifiers so you can verify and retry.
set_grace_period string | null no null Set or modify the grace period. Accepts three forms: (1) A date in YYYY-MM-DD format — grace period ends at 11:59 PM Pacific on that date. (2) An integer number of days (1-90) from today (Pacific Time) — grace period ends at 11:59 PM Pacific on the resulting date. (3) 'cancel' to terminate the current grace period (sets status to manual). Requires set_grace_period_reason when setting or extending.
set_grace_period_reason string | null no null Reason for starting, extending, or canceling the grace period. Required when set_grace_period is a date or number of days.
config_api_root string | null no null Optional Config API root URL override. Defaults to Airbyte Cloud (https://cloud.airbyte.com/api/v1).

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "organization_id": {
      "description": "The organization UUID.",
      "type": "string"
    },
    "approval_comment_url": {
      "description": "URL to the Slack approval record. Obtain this by calling `escalate_to_human` with `approval_requested=True`; the backend delivers the approval record URL when a human clicks Approve.",
      "type": "string"
    },
    "organization_name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Confirmation of the target organization. Accepts the organization name, email address, or email domain. Required to prevent accidental modifications to the wrong organization. If omitted or mismatched, the tool returns an error with the valid identifiers so you can verify and retry."
    },
    "set_grace_period": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Set or modify the grace period. Accepts three forms: (1) A date in `YYYY-MM-DD` format \u2014 grace period ends at 11:59 PM Pacific on that date. (2) An integer number of days (1-90) from today (Pacific Time) \u2014 grace period ends at 11:59 PM Pacific on the resulting date. (3) `'cancel'` to terminate the current grace period (sets status to `manual`). Requires `set_grace_period_reason` when setting or extending."
    },
    "set_grace_period_reason": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Reason for starting, extending, or canceling the grace period. Required when `set_grace_period` is a date or number of days."
    },
    "config_api_root": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional Config API root URL override. Defaults to Airbyte Cloud (`https://cloud.airbyte.com/api/v1`)."
    }
  },
  "required": [
    "organization_id",
    "approval_comment_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Result of an organization payment config update operation.",
  "properties": {
    "success": {
      "description": "Whether the operation succeeded",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable message describing the result",
      "type": "string"
    },
    "organization_id": {
      "description": "The organization UUID",
      "type": "string"
    },
    "payment_status": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The payment status after the update"
    },
    "grace_period_end_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The grace period end datetime after the update (if applicable)"
    },
    "customer_tier": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Customer tier of the organization (TIER_0, TIER_1, TIER_2)"
    },
    "tier_warning": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Warning message if the organization is a sensitive customer tier"
    }
  },
  "required": [
    "success",
    "message",
    "organization_id"
  ],
  "type": "object"
}

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""MCP tools for organization payment config management.
  3
  4Provides tools to read and update organization payment configurations
  5in Airbyte Cloud, including grace period management and payment status changes.
  6
  7## MCP reference
  8
  9.. include:: ../../../docs/mcp-generated/organization_payment_config.md
 10    :start-line: 2
 11"""
 12
 13# NOTE: We intentionally do NOT use `from __future__ import annotations` here.
 14# FastMCP has issues resolving forward references when PEP 563 deferred annotations
 15# are used. See: https://github.com/jlowin/fastmcp/issues/905
 16
 17__all__: list[str] = []
 18
 19import logging
 20import re
 21from datetime import date, datetime, timedelta, timezone
 22from typing import Annotated
 23
 24from airbyte import constants
 25from fastmcp import Context, FastMCP
 26from fastmcp_extensions import get_mcp_config, mcp_tool, register_mcp_tools
 27from pydantic import Field
 28from zoneinfo import ZoneInfo
 29
 30from airbyte_ops_mcp.approval_resolution import (
 31    ApprovalResolutionError,
 32    resolve_admin_email_from_approval,
 33)
 34from airbyte_ops_mcp.cloud_admin.auth import (
 35    CloudAuthError,
 36    require_internal_admin_flag_only,
 37)
 38from airbyte_ops_mcp.cloud_admin.models import (
 39    OrganizationInfo,
 40    OrganizationPaymentConfigInfo,
 41    OrganizationPaymentConfigUpdateResult,
 42)
 43from airbyte_ops_mcp.cloud_admin.payment_config import (
 44    PaymentConfigAPIError,
 45    get_organization_info,
 46)
 47from airbyte_ops_mcp.cloud_admin.payment_config import (
 48    get_organization_payment_config as _get_organization_payment_config,
 49)
 50from airbyte_ops_mcp.cloud_admin.payment_config import (
 51    update_organization_payment_config as _update_organization_payment_config,
 52)
 53from airbyte_ops_mcp.constants import ServerConfigKey
 54from airbyte_ops_mcp.tier_cache import get_org_tier
 55
 56logger = logging.getLogger(__name__)
 57
 58_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
 59_DAYS_PATTERN = re.compile(r"^\d+$")
 60_PACIFIC = ZoneInfo("America/Los_Angeles")
 61
 62
 63def _build_tier_warning(customer_tier: str) -> str | None:
 64    """Build a warning message for sensitive customer tiers."""
 65    if customer_tier == "TIER_0":
 66        return (
 67            "WARNING: This is a TIER_0 (highest-value) customer. "
 68            "Proceed with extreme caution."
 69        )
 70    if customer_tier == "TIER_1":
 71        return "WARNING: This is a TIER_1 (high-value) customer. Proceed with caution."
 72    return None
 73
 74
 75def _resolve_cloud_auth(ctx: Context) -> tuple[str | None, str | None, str | None]:
 76    """Resolve auth credentials, returning `(bearer_token, client_id, client_secret)`."""
 77    bearer_token = get_mcp_config(ctx, ServerConfigKey.BEARER_TOKEN)
 78    if bearer_token:
 79        return bearer_token, None, None
 80
 81    client_id = get_mcp_config(ctx, ServerConfigKey.CLIENT_ID)
 82    client_secret = get_mcp_config(ctx, ServerConfigKey.CLIENT_SECRET)
 83    return None, client_id, client_secret
 84
 85
 86def _validate_organization_name(
 87    organization_id: str,
 88    organization_name: str | None,
 89    org_info: OrganizationInfo,
 90) -> tuple[bool, str | None]:
 91    """Validate `organization_name` against the org record from the Config API.
 92
 93    Accepts the org's literal name, email, or email domain as valid inputs.
 94
 95    Returns `(ok, error_message)`. On success `error_message` is `None`.
 96    """
 97    org_db_name: str = org_info.organization_name or ""
 98    org_db_email: str = org_info.email or ""
 99    org_db_domain: str = org_db_email.split("@", 1)[-1] if "@" in org_db_email else ""
100
101    valid_identifiers = [
102        v
103        for v in [org_db_name.lower(), org_db_email.lower(), org_db_domain.lower()]
104        if v
105    ]
106
107    identifier_parts = []
108    if org_db_name:
109        identifier_parts.append(f"'{org_db_name}' (org name)")
110    if org_db_email:
111        identifier_parts.append(f"'{org_db_email}' (email)")
112    if org_db_domain:
113        identifier_parts.append(f"'{org_db_domain}' (email domain)")
114
115    if not identifier_parts:
116        return (
117            False,
118            f"Organization {organization_id} has no name or email on record.",
119        )
120
121    hint_message = (
122        f"To confirm, resend with `organization_name` set to one of: "
123        f"{', '.join(identifier_parts)}. "
124        f"Double-check that this is the correct organization before retrying. "
125        f"If there is any doubt, confirm with your user."
126    )
127
128    if organization_name is None:
129        return (
130            False,
131            (
132                f"`organization_name` is required to confirm the target organization. "
133                f"{hint_message}"
134            ),
135        )
136
137    if organization_name.strip().lower().lstrip("@") not in valid_identifiers:
138        return (
139            False,
140            (
141                f"Organization name mismatch: '{organization_name}' does not match "
142                f"organization {organization_id}. {hint_message}"
143            ),
144        )
145
146    return True, None
147
148
149def _format_grace_period_end(target_date: date) -> str:
150    """Build a backend-compatible datetime string for 11:59 PM Pacific on `target_date`.
151
152    The result is converted to UTC and formatted as
153    `yyyy-MM-dd'T'HH:mm:ss.SSS+0000` to match the Java `@JsonFormat` annotation.
154    """
155    end_pacific = datetime(
156        target_date.year,
157        target_date.month,
158        target_date.day,
159        23,
160        59,
161        59,
162        tzinfo=_PACIFIC,
163    )
164    end_utc = end_pacific.astimezone(timezone.utc)
165    return end_utc.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
166
167
168def _parse_grace_period_value(
169    value: str,
170) -> tuple[str | None, str | None]:
171    """Parse `set_grace_period` into an ISO 8601 datetime string or an action keyword.
172
173    Dates and day offsets resolve to 11:59 PM Pacific Time on the target date.
174    Day offsets use the current date in Pacific Time as the starting point.
175
176    Returns `(iso_datetime_or_action, error_message)`.
177    On success `error_message` is `None`.
178    """
179    value = value.strip()
180
181    if value.lower() == "cancel":
182        return "cancel", None
183
184    today_pacific = datetime.now(tz=_PACIFIC).date()
185
186    if _DATE_PATTERN.match(value):
187        try:
188            parsed = date.fromisoformat(value)
189        except ValueError:
190            return None, f"Invalid date: {value}. Use YYYY-MM-DD format."
191        if parsed < today_pacific:
192            return None, f"Grace period end date {value} is in the past."
193        if (parsed - today_pacific).days > 90:
194            return None, (
195                f"Grace period end date {value} is more than 90 days in the future. "
196                "Maximum grace period is 90 days."
197            )
198        return _format_grace_period_end(parsed), None
199
200    if _DAYS_PATTERN.match(value):
201        days = int(value)
202        if days < 1 or days > 90:
203            return None, f"Days must be between 1 and 90, got {days}."
204        target_date = today_pacific + timedelta(days=days)
205        return _format_grace_period_end(target_date), None
206
207    return None, (
208        f"Invalid `set_grace_period` value: '{value}'. "
209        "Expected a date (YYYY-MM-DD), an integer number of days, or 'cancel'."
210    )
211
212
213# =============================================================================
214# MCP Tools
215# =============================================================================
216
217
218@mcp_tool(
219    read_only=True,
220    idempotent=True,
221    open_world=True,
222)
223def get_organization_payment_config(
224    organization_id: Annotated[
225        str,
226        "The organization UUID.",
227    ],
228    config_api_root: Annotated[
229        str | None,
230        Field(
231            description="Optional Config API root URL override. "
232            "Defaults to Airbyte Cloud (`https://cloud.airbyte.com/api/v1`).",
233            default=None,
234        ),
235    ] = None,
236    *,
237    ctx: Context,
238) -> OrganizationPaymentConfigInfo:
239    """Get the current payment configuration for an organization.
240
241    Returns payment status, subscription status, grace period info,
242    and usage category override. No PII or sensitive payment details
243    are included in the response.
244
245    Authentication credentials are resolved in priority order:
246    1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
247    2. HTTP headers: X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret
248    3. Environment variables: AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET
249    """
250    resolved_api_root = config_api_root or constants.CLOUD_CONFIG_API_ROOT
251    bearer_token, client_id, client_secret = _resolve_cloud_auth(ctx)
252
253    data = _get_organization_payment_config(
254        organization_id=organization_id,
255        config_api_root=resolved_api_root,
256        client_id=client_id,
257        client_secret=client_secret,
258        bearer_token=bearer_token,
259    )
260
261    # Enrich with tier info
262    tier_result = get_org_tier(organization_id)
263    tier_warning = _build_tier_warning(tier_result.customer_tier)
264
265    return OrganizationPaymentConfigInfo(
266        organization_id=data["organizationId"],
267        payment_status=data["paymentStatus"],
268        subscription_status=data["subscriptionStatus"],
269        payment_provider_id=data.get("paymentProviderId"),
270        grace_period_end_at=data.get("gracePeriodEndAt"),
271        usage_category_overwrite=data.get("usageCategoryOverwrite"),
272        customer_tier=tier_result.customer_tier,
273        tier_warning=tier_warning,
274    )
275
276
277@mcp_tool(
278    destructive=True,
279    idempotent=False,
280    open_world=True,
281)
282def update_organization_payment_config(
283    organization_id: Annotated[
284        str,
285        "The organization UUID.",
286    ],
287    approval_comment_url: Annotated[
288        str,
289        Field(
290            description="URL to the Slack approval record. Obtain this by calling "
291            "`escalate_to_human` with `approval_requested=True`; the backend "
292            "delivers the approval record URL when a human clicks Approve.",
293        ),
294    ],
295    organization_name: Annotated[
296        str | None,
297        Field(
298            description="Confirmation of the target organization. Accepts the "
299            "organization name, email address, or email domain. Required to "
300            "prevent accidental modifications to the wrong organization. "
301            "If omitted or mismatched, the tool returns an error with the valid "
302            "identifiers so you can verify and retry.",
303            default=None,
304        ),
305    ] = None,
306    set_grace_period: Annotated[
307        str | None,
308        Field(
309            description="Set or modify the grace period. Accepts three forms: "
310            "(1) A date in `YYYY-MM-DD` format — grace period ends at 11:59 PM Pacific on that date. "
311            "(2) An integer number of days (1-90) from today (Pacific Time) — "
312            "grace period ends at 11:59 PM Pacific on the resulting date. "
313            "(3) `'cancel'` to terminate the current grace period "
314            "(sets status to `manual`). "
315            "Requires `set_grace_period_reason` when setting or extending.",
316            default=None,
317        ),
318    ] = None,
319    set_grace_period_reason: Annotated[
320        str | None,
321        Field(
322            description="Reason for starting, extending, or canceling the grace "
323            "period. Required when `set_grace_period` is a date or number of days.",
324            default=None,
325        ),
326    ] = None,
327    config_api_root: Annotated[
328        str | None,
329        Field(
330            description="Optional Config API root URL override. "
331            "Defaults to Airbyte Cloud (`https://cloud.airbyte.com/api/v1`).",
332            default=None,
333        ),
334    ] = None,
335    *,
336    ctx: Context,
337) -> OrganizationPaymentConfigUpdateResult:
338    """Update the payment configuration for an organization.
339
340    All updates require human-in-the-loop approval via `escalate_to_human`.
341
342    Use `set_grace_period` to start, extend, or cancel a grace period.
343    If the org is not already in `manual` status, the tool automatically
344    transitions to `manual` first before setting the grace period.
345
346    The `organization_name` parameter is a safety check: the tool looks up the
347    organization via the Config API and verifies that the provided name, email, or
348    email domain matches. If omitted or mismatched, the tool returns the valid
349    identifiers so the caller can verify and retry.
350    """
351    # --- Validate that an action was specified ---
352    if set_grace_period is None:
353        return OrganizationPaymentConfigUpdateResult(
354            success=False,
355            message="No action specified. Provide `set_grace_period` with a date "
356            "(YYYY-MM-DD), number of days (1-90), or 'cancel'.",
357            organization_id=organization_id,
358        )
359
360    # --- Validate admin access ---
361    try:
362        require_internal_admin_flag_only()
363    except CloudAuthError as e:
364        return OrganizationPaymentConfigUpdateResult(
365            success=False,
366            message=f"Admin authentication failed: {e}",
367            organization_id=organization_id,
368        )
369
370    # --- Resolve approval ---
371    try:
372        resolve_admin_email_from_approval(
373            approval_comment_url=approval_comment_url,
374        )
375    except ApprovalResolutionError as e:
376        return OrganizationPaymentConfigUpdateResult(
377            success=False,
378            message=str(e),
379            organization_id=organization_id,
380        )
381
382    # --- Resolve auth ---
383    resolved_api_root = config_api_root or constants.CLOUD_CONFIG_API_ROOT
384    bearer_token, client_id, client_secret = _resolve_cloud_auth(ctx)
385
386    # --- Look up organization info via Config API ---
387    try:
388        org_info = get_organization_info(
389            organization_id=organization_id,
390            config_api_root=resolved_api_root,
391            client_id=client_id,
392            client_secret=client_secret,
393            bearer_token=bearer_token,
394        )
395    except PaymentConfigAPIError as e:
396        return OrganizationPaymentConfigUpdateResult(
397            success=False,
398            message=f"Failed to fetch organization info: {e}",
399            organization_id=organization_id,
400        )
401    if org_info is None:
402        return OrganizationPaymentConfigUpdateResult(
403            success=False,
404            message=f"Organization {organization_id} not found.",
405            organization_id=organization_id,
406        )
407
408    # --- Validate organization name (safety check) ---
409    name_ok, name_error = _validate_organization_name(
410        organization_id, organization_name, org_info
411    )
412    if not name_ok:
413        return OrganizationPaymentConfigUpdateResult(
414            success=False,
415            message=name_error or "Organization name validation failed.",
416            organization_id=organization_id,
417        )
418
419    # --- Enrich with tier info ---
420    tier_result = get_org_tier(organization_id)
421    customer_tier = tier_result.customer_tier
422    tier_warning = _build_tier_warning(customer_tier)
423
424    # --- Parse grace period value ---
425    parsed_value, parse_error = _parse_grace_period_value(set_grace_period)
426    if parse_error is not None:
427        return OrganizationPaymentConfigUpdateResult(
428            success=False,
429            message=parse_error,
430            organization_id=organization_id,
431            customer_tier=customer_tier,
432            tier_warning=tier_warning,
433        )
434
435    assert parsed_value is not None
436
437    # --- Fetch current payment config (needed for both cancel and set paths) ---
438    try:
439        current_config = _get_organization_payment_config(
440            organization_id=organization_id,
441            config_api_root=resolved_api_root,
442            client_id=client_id,
443            client_secret=client_secret,
444            bearer_token=bearer_token,
445        )
446    except PaymentConfigAPIError as e:
447        return OrganizationPaymentConfigUpdateResult(
448            success=False,
449            message=f"Failed to fetch current config: {e}",
450            organization_id=organization_id,
451            customer_tier=customer_tier,
452            tier_warning=tier_warning,
453        )
454
455    current_status = current_config["paymentStatus"]
456
457    if parsed_value == "cancel":
458        # Only allow cancel when org is actually in grace_period (or manual with
459        # an active grace period end date).
460        if current_status not in ("grace_period", "manual"):
461            return OrganizationPaymentConfigUpdateResult(
462                success=False,
463                message=f"Cannot cancel grace period: organization is in "
464                f"'{current_status}' status, not 'grace_period'.",
465                organization_id=organization_id,
466                payment_status=current_status,
467                customer_tier=customer_tier,
468                tier_warning=tier_warning,
469            )
470
471        # Cancel grace period by setting status to manual
472        try:
473            data = _update_organization_payment_config(
474                organization_id=organization_id,
475                payment_status="manual",
476                config_api_root=resolved_api_root,
477                client_id=client_id,
478                client_secret=client_secret,
479                bearer_token=bearer_token,
480                new_grace_period_reason=(
481                    set_grace_period_reason or "Grace period canceled via MCP tool"
482                ),
483            )
484        except PaymentConfigAPIError as e:
485            return OrganizationPaymentConfigUpdateResult(
486                success=False,
487                message=str(e),
488                organization_id=organization_id,
489                customer_tier=customer_tier,
490                tier_warning=tier_warning,
491            )
492
493        return OrganizationPaymentConfigUpdateResult(
494            success=True,
495            message=f"Grace period canceled for org {organization_id}. "
496            f"New status: {data['paymentStatus']}.",
497            organization_id=organization_id,
498            payment_status=data["paymentStatus"],
499            grace_period_end_at=data.get("gracePeriodEndAt"),
500            customer_tier=customer_tier,
501            tier_warning=tier_warning,
502        )
503
504    # --- Setting/extending grace period ---
505    if not set_grace_period_reason:
506        return OrganizationPaymentConfigUpdateResult(
507            success=False,
508            message="`set_grace_period_reason` is required when setting or "
509            "extending a grace period.",
510            organization_id=organization_id,
511            customer_tier=customer_tier,
512            tier_warning=tier_warning,
513        )
514
515    # The API only allows setting grace_period from manual status.
516    # If not already in manual, transition to manual first.
517    transitioned_to_manual = False
518    if current_status != "manual":
519        logger.info(
520            "Org %s is in '%s' status; transitioning to 'manual' first.",
521            organization_id,
522            current_status,
523        )
524        try:
525            _update_organization_payment_config(
526                organization_id=organization_id,
527                payment_status="manual",
528                config_api_root=resolved_api_root,
529                client_id=client_id,
530                client_secret=client_secret,
531                bearer_token=bearer_token,
532            )
533            transitioned_to_manual = True
534        except PaymentConfigAPIError as e:
535            return OrganizationPaymentConfigUpdateResult(
536                success=False,
537                message=f"Failed to transition to 'manual' from '{current_status}': {e}",
538                organization_id=organization_id,
539                customer_tier=customer_tier,
540                tier_warning=tier_warning,
541            )
542
543    # Now set the grace period
544    try:
545        data = _update_organization_payment_config(
546            organization_id=organization_id,
547            payment_status="grace_period",
548            config_api_root=resolved_api_root,
549            client_id=client_id,
550            client_secret=client_secret,
551            bearer_token=bearer_token,
552            grace_period_end_at=parsed_value,
553            new_grace_period_reason=set_grace_period_reason,
554        )
555    except PaymentConfigAPIError as e:
556        if transitioned_to_manual:
557            msg = (
558                f"Failed to set grace period after transitioning to 'manual' "
559                f"from '{current_status}'. The organization is now in 'manual' "
560                f"status. Original error: {e}"
561            )
562        else:
563            msg = str(e)
564        return OrganizationPaymentConfigUpdateResult(
565            success=False,
566            message=msg,
567            organization_id=organization_id,
568            payment_status="manual" if transitioned_to_manual else None,
569            customer_tier=customer_tier,
570            tier_warning=tier_warning,
571        )
572
573    return OrganizationPaymentConfigUpdateResult(
574        success=True,
575        message=f"Grace period set for org {organization_id}. "
576        f"New status: {data['paymentStatus']}.",
577        organization_id=organization_id,
578        payment_status=data["paymentStatus"],
579        grace_period_end_at=data.get("gracePeriodEndAt"),
580        customer_tier=customer_tier,
581        tier_warning=tier_warning,
582    )
583
584
585def register_organization_payment_config_tools(app: FastMCP) -> None:
586    """Register organization payment config tools with the FastMCP app."""
587    register_mcp_tools(app, mcp_module=__name__)