Skip to content

Tools API

Auto-generated API reference for all MCP tool functions. Each tool module exposes public functions decorated with @mcp.tool() that handle a specific YNAB domain.

All tools use an action-based dispatch pattern: a single entry function accepts an action parameter that routes to the appropriate operation.


Budgets

budgets

Budget tools: list budgets, get budget detail, get user info.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

_list_budgets async

_list_budgets(app: AppContext) -> str

List all available YNAB budgets.

Parameters:

  • app (AppContext) –

    The application context with client and cache.

Returns:

  • str

    Structured text with count header and budget details.

Source code in src/ynaa_mcp/tools/budgets.py
async def _list_budgets(app: AppContext) -> str:
    """List all available YNAB budgets.

    Args:
        app: The application context with client and cache.

    Returns:
        Structured text with count header and budget details.
    """
    data = await app.client.get("/budgets")
    budgets = data["budgets"]

    if not budgets:
        return "No budgets found."

    lines = [f"{len(budgets)} budgets found:"]
    for b in budgets:
        lines.extend((
            f"- {b['name']}",
            f"  ID: {b['id']}",
            f"  Last modified: {b['last_modified_on']}",
        ))
    return "\n".join(lines)

_get_budget async

_get_budget(app: AppContext, budget_id: str, info: str | None) -> str

Get detailed information about a YNAB budget.

Parameters:

  • app (AppContext) –

    The application context with client and cache.

  • budget_id (str) –

    Resolved budget UUID.

  • info (str | None) –

    Optional info message from budget resolution.

Returns:

  • str

    Structured text with budget details and settings.

Source code in src/ynaa_mcp/tools/budgets.py
async def _get_budget(
    app: AppContext,
    budget_id: str,
    info: str | None,
) -> str:
    """Get detailed information about a YNAB budget.

    Args:
        app: The application context with client and cache.
        budget_id: Resolved budget UUID.
        info: Optional info message from budget resolution.

    Returns:
        Structured text with budget details and settings.
    """
    data = await app.client.get(f"/budgets/{budget_id}")
    budget = data["budget"]

    settings_data = await app.client.get(f"/budgets/{budget_id}/settings")
    settings = settings_data["settings"]

    date_fmt = settings.get("date_format", {}).get("format", "N/A")
    currency = settings.get("currency_format", {}).get("iso_code", "N/A")

    lines = [
        f"Budget: {budget['name']}",
        f"  ID: {budget['id']}",
        f"  First month: {budget['first_month']}",
        f"  Last month: {budget['last_month']}",
        f"  Date format: {date_fmt}",
        f"  Currency: {currency}",
    ]
    result = "\n".join(lines)

    if info:
        result = f"{info}\n\n{result}"
    return result

_get_user async

_get_user(app: AppContext) -> str

Get the authenticated YNAB user's information.

Parameters:

  • app (AppContext) –

    The application context with client and cache.

Returns:

  • str

    Structured text with the user ID.

Source code in src/ynaa_mcp/tools/budgets.py
async def _get_user(app: AppContext) -> str:
    """Get the authenticated YNAB user's information.

    Args:
        app: The application context with client and cache.

    Returns:
        Structured text with the user ID.
    """
    data = await app.client.get("/user")
    user = data["user"]
    return f"User ID: {user['id']}"

manage_budgets async

manage_budgets(
    ctx: Context,
    action: Literal["list", "get", "get_user"],
    budget_id_or_name: str | None = None,
) -> str

Manage YNAB budgets: list all, get details, or get user info.

Actions

list: List all budgets. No extra params needed. get: Get budget details. Uses budget_id_or_name. get_user: Get authenticated user info. No extra params needed.

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'get_user']) –

    The operation to perform.

  • budget_id_or_name (str | None, default: None ) –

    Budget UUID or name (get only). Auto-resolves if only one budget exists.

Returns:

  • str

    Structured text with budget or user information.

Source code in src/ynaa_mcp/tools/budgets.py
@mcp.tool
async def manage_budgets(
    ctx: Context,
    action: Literal["list", "get", "get_user"],
    budget_id_or_name: str | None = None,
) -> str:
    """Manage YNAB budgets: list all, get details, or get user info.

    Actions:
        list: List all budgets. No extra params needed.
        get: Get budget details. Uses budget_id_or_name.
        get_user: Get authenticated user info. No extra params needed.

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name (get only). Auto-resolves
            if only one budget exists.

    Returns:
        Structured text with budget or user information.
    """
    app = cast("AppContext", ctx.lifespan_context)

    if action == "list":
        return await _list_budgets(app)

    if action == "get":
        budget_id, info = await resolve_budget(
            app.client, budget_id_or_name, cache=app.cache
        )
        return await _get_budget(app, budget_id, info)

    return await _get_user(app)

Accounts

accounts

Account tools: list, detail, and create YNAB accounts.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

dollars_to_milliunits

dollars_to_milliunits(dollars: float) -> int

Convert a dollar amount to YNAB milliunits.

Uses Decimal(str(dollars)) to avoid floating-point precision issues, then rounds to the nearest milliunit using ROUND_HALF_UP.

Parameters:

  • dollars (float) –

    Dollar amount to convert.

Returns:

  • int

    The equivalent amount in YNAB milliunits as an integer.

Examples:

>>> dollars_to_milliunits(45.67)
45670
>>> dollars_to_milliunits(0.1 + 0.2)
300
Source code in src/ynaa_mcp/converters.py
def dollars_to_milliunits(dollars: float) -> int:
    """Convert a dollar amount to YNAB milliunits.

    Uses ``Decimal(str(dollars))`` to avoid floating-point precision issues,
    then rounds to the nearest milliunit using ROUND_HALF_UP.

    Args:
        dollars: Dollar amount to convert.

    Returns:
        The equivalent amount in YNAB milliunits as an integer.

    Examples:
        >>> dollars_to_milliunits(45.67)
        45670
        >>> dollars_to_milliunits(0.1 + 0.2)
        300
    """
    result = Decimal(str(dollars)) * MILLIUNIT_FACTOR
    return int(result.quantize(Decimal(1), rounding=ROUND_HALF_UP))

format_dollars

format_dollars(amount: float) -> str

Format a dollar amount with $ symbol and comma separators.

Parameters:

  • amount (float) –

    Dollar amount (already converted from milliunits).

Returns:

  • str

    Formatted string like "$1,234.56" or "-$1,234.56".

Examples:

>>> format_dollars(1234.56)
'$1,234.56'
>>> format_dollars(-50.0)
'-$50.00'
>>> format_dollars(0.0)
'$0.00'
Source code in src/ynaa_mcp/converters.py
def format_dollars(amount: float) -> str:
    """Format a dollar amount with $ symbol and comma separators.

    Args:
        amount: Dollar amount (already converted from milliunits).

    Returns:
        Formatted string like "$1,234.56" or "-$1,234.56".

    Examples:
        >>> format_dollars(1234.56)
        '$1,234.56'
        >>> format_dollars(-50.0)
        '-$50.00'
        >>> format_dollars(0.0)
        '$0.00'
    """
    if amount < 0:
        return f"-${abs(amount):,.2f}"
    return f"${amount:,.2f}"

_list_accounts async

_list_accounts(
    app: AppContext, budget_id: str, info: str | None, *, include_closed: bool = False
) -> str

List all accounts in a YNAB budget.

Parameters:

  • app (AppContext) –

    The application context with client and cache.

  • budget_id (str) –

    Resolved budget UUID.

  • info (str | None) –

    Optional info message from budget resolution.

  • include_closed (bool, default: False ) –

    If True, include closed accounts in the list.

Returns:

  • str

    Structured text with count header and account details.

Source code in src/ynaa_mcp/tools/accounts.py
async def _list_accounts(
    app: AppContext,
    budget_id: str,
    info: str | None,
    *,
    include_closed: bool = False,
) -> str:
    """List all accounts in a YNAB budget.

    Args:
        app: The application context with client and cache.
        budget_id: Resolved budget UUID.
        info: Optional info message from budget resolution.
        include_closed: If True, include closed accounts in the list.

    Returns:
        Structured text with count header and account details.
    """
    data = await app.client.get(f"/budgets/{budget_id}/accounts")
    all_accounts = data["accounts"]

    # Always exclude deleted
    accounts = [a for a in all_accounts if not a["deleted"]]
    had_accounts = len(accounts) > 0

    # Filter closed unless requested
    if not include_closed:
        accounts = [a for a in accounts if not a["closed"]]

    if not accounts:
        if had_accounts and not include_closed:
            msg = "No open accounts found."
        else:
            msg = "No accounts found."
        if info:
            msg = f"{info}\n\n{msg}"
        return msg

    count = len(accounts)
    noun = "account" if count == 1 else "accounts"
    lines = [f"{count} {noun} found:"]
    for a in accounts:
        lines.extend((
            f"- {a['name']}",
            f"  ID: {a['id']}",
            f"  Type: {a['type']}",
            f"  Balance: {format_dollars(a['balance'])}",
        ))

    result = "\n".join(lines)
    if info:
        result = f"{info}\n\n{result}"
    return result

_get_account async

_get_account(
    app: AppContext, budget_id: str, info: str | None, *, account_id: str
) -> str

Get detailed information about a specific YNAB account.

Parameters:

  • app (AppContext) –

    The application context with client and cache.

  • budget_id (str) –

    Resolved budget UUID.

  • info (str | None) –

    Optional info message from budget resolution.

  • account_id (str) –

    The account UUID.

Returns:

  • str

    Structured text with full account details.

Source code in src/ynaa_mcp/tools/accounts.py
async def _get_account(
    app: AppContext,
    budget_id: str,
    info: str | None,
    *,
    account_id: str,
) -> str:
    """Get detailed information about a specific YNAB account.

    Args:
        app: The application context with client and cache.
        budget_id: Resolved budget UUID.
        info: Optional info message from budget resolution.
        account_id: The account UUID.

    Returns:
        Structured text with full account details.
    """
    data = await app.client.get(f"/budgets/{budget_id}/accounts/{account_id}")
    acct = data["account"]

    lines = [
        f"Account: {acct['name']}",
        f"  ID: {acct['id']}",
        f"  Type: {acct['type']}",
        f"  On budget: {'Yes' if acct['on_budget'] else 'No'}",
        f"  Closed: {'Yes' if acct['closed'] else 'No'}",
        f"  Balance: {format_dollars(acct['balance'])}",
        f"  Cleared balance: {format_dollars(acct['cleared_balance'])}",
        f"  Uncleared balance: {format_dollars(acct['uncleared_balance'])}",
    ]
    if acct.get("note"):
        lines.append(f"  Note: {acct['note']}")

    result = "\n".join(lines)
    if info:
        result = f"{info}\n\n{result}"
    return result

_create_account async

_create_account(
    app: AppContext,
    budget_id: str,
    info: str | None,
    *,
    name: str,
    account_type: str,
    balance: float,
) -> str

Create a new account in a YNAB budget.

Parameters:

  • app (AppContext) –

    The application context with client and cache.

  • budget_id (str) –

    Resolved budget UUID.

  • info (str | None) –

    Optional info message from budget resolution.

  • name (str) –

    Display name for the new account.

  • account_type (str) –

    Account type string.

  • balance (float) –

    Opening balance in dollars.

Returns:

  • str

    Confirmation text with created account details.

Source code in src/ynaa_mcp/tools/accounts.py
async def _create_account(  # noqa: PLR0913
    app: AppContext,
    budget_id: str,
    info: str | None,
    *,
    name: str,
    account_type: str,
    balance: float,
) -> str:
    """Create a new account in a YNAB budget.

    Args:
        app: The application context with client and cache.
        budget_id: Resolved budget UUID.
        info: Optional info message from budget resolution.
        name: Display name for the new account.
        account_type: Account type string.
        balance: Opening balance in dollars.

    Returns:
        Confirmation text with created account details.
    """
    milliunits = dollars_to_milliunits(balance)
    data = await app.client.post(
        f"/budgets/{budget_id}/accounts",
        json={
            "account": {
                "name": name,
                "type": account_type,
                "balance": milliunits,
            }
        },
    )
    acct = data["account"]

    lines = [
        "Account created:",
        f"  Name: {acct['name']}",
        f"  Type: {acct['type']}",
        f"  Balance: {format_dollars(acct['balance'])}",
        f"  ID: {acct['id']}",
    ]

    result = "\n".join(lines)
    if info:
        result = f"{info}\n\n{result}"
    return result

manage_accounts async

manage_accounts(
    ctx: Context,
    action: Literal["list", "get", "create"],
    budget_id_or_name: str | None = None,
    include_closed: bool = False,
    account_id: str | None = None,
    name: str | None = None,
    account_type: str | None = None,
    balance: float | None = None,
) -> str

Manage YNAB accounts: list, get details, or create.

Actions

list: List all accounts. Uses budget_id_or_name, include_closed. get: Get account details. Uses budget_id_or_name, account_id (required). create: Create account. Uses budget_id_or_name, name (required), account_type (required), balance (required).

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'create']) –

    The operation to perform.

  • budget_id_or_name (str | None, default: None ) –

    Budget UUID or name. Auto-resolves if only one budget exists.

  • include_closed (bool, default: False ) –

    If True, include closed accounts (list only).

  • account_id (str | None, default: None ) –

    The account UUID (get only).

  • name (str | None, default: None ) –

    Display name for the new account (create only).

  • account_type (str | None, default: None ) –

    Account type (create only).

  • balance (float | None, default: None ) –

    Opening balance in dollars (create only).

Returns:

  • str

    Structured text with account information or confirmation.

Raises:

  • ToolError

    If required parameters for the action are missing.

Source code in src/ynaa_mcp/tools/accounts.py
@mcp.tool
async def manage_accounts(  # noqa: PLR0913, PLR0917
    ctx: Context,
    action: Literal["list", "get", "create"],
    budget_id_or_name: str | None = None,
    include_closed: bool = False,  # noqa: FBT001, FBT002
    account_id: str | None = None,
    name: str | None = None,
    account_type: str | None = None,
    balance: float | None = None,
) -> str:
    """Manage YNAB accounts: list, get details, or create.

    Actions:
        list: List all accounts. Uses budget_id_or_name, include_closed.
        get: Get account details. Uses budget_id_or_name, account_id (required).
        create: Create account. Uses budget_id_or_name, name (required),
            account_type (required), balance (required).

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name. Auto-resolves if only
            one budget exists.
        include_closed: If True, include closed accounts (list only).
        account_id: The account UUID (get only).
        name: Display name for the new account (create only).
        account_type: Account type (create only).
        balance: Opening balance in dollars (create only).

    Returns:
        Structured text with account information or confirmation.

    Raises:
        ToolError: If required parameters for the action are missing.
    """
    app = cast("AppContext", ctx.lifespan_context)
    budget_id, info = await resolve_budget(
        app.client, budget_id_or_name, cache=app.cache
    )

    if action == "list":
        return await _list_accounts(app, budget_id, info, include_closed=include_closed)

    if action == "get":
        if account_id is None:
            msg = "account_id is required for action='get'"
            raise ToolError(msg)
        return await _get_account(app, budget_id, info, account_id=account_id)

    if name is None or account_type is None or balance is None:
        msg = "name, account_type, and balance are required for action='create'"
        raise ToolError(msg)
    return await _create_account(
        app, budget_id, info, name=name, account_type=account_type, balance=balance
    )

Categories

categories

Category tools: list, detail, create/update categories and groups.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

_GOAL_TYPE_LABELS module-attribute

_GOAL_TYPE_LABELS: dict[str, str] = {
    "TB": "Target Balance",
    "TBD": "Target Balance by Date",
    "MF": "Monthly Funding",
    "NEED": "Needed for Spending",
    "DEBT": "Debt",
}

Human-readable labels for YNAB goal type codes.

_MAX_GROUP_NAME_LENGTH module-attribute

_MAX_GROUP_NAME_LENGTH = 50

Maximum character length for a category group name.

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

dollars_to_milliunits

dollars_to_milliunits(dollars: float) -> int

Convert a dollar amount to YNAB milliunits.

Uses Decimal(str(dollars)) to avoid floating-point precision issues, then rounds to the nearest milliunit using ROUND_HALF_UP.

Parameters:

  • dollars (float) –

    Dollar amount to convert.

Returns:

  • int

    The equivalent amount in YNAB milliunits as an integer.

Examples:

>>> dollars_to_milliunits(45.67)
45670
>>> dollars_to_milliunits(0.1 + 0.2)
300
Source code in src/ynaa_mcp/converters.py
def dollars_to_milliunits(dollars: float) -> int:
    """Convert a dollar amount to YNAB milliunits.

    Uses ``Decimal(str(dollars))`` to avoid floating-point precision issues,
    then rounds to the nearest milliunit using ROUND_HALF_UP.

    Args:
        dollars: Dollar amount to convert.

    Returns:
        The equivalent amount in YNAB milliunits as an integer.

    Examples:
        >>> dollars_to_milliunits(45.67)
        45670
        >>> dollars_to_milliunits(0.1 + 0.2)
        300
    """
    result = Decimal(str(dollars)) * MILLIUNIT_FACTOR
    return int(result.quantize(Decimal(1), rounding=ROUND_HALF_UP))

format_dollars

format_dollars(amount: float) -> str

Format a dollar amount with $ symbol and comma separators.

Parameters:

  • amount (float) –

    Dollar amount (already converted from milliunits).

Returns:

  • str

    Formatted string like "$1,234.56" or "-$1,234.56".

Examples:

>>> format_dollars(1234.56)
'$1,234.56'
>>> format_dollars(-50.0)
'-$50.00'
>>> format_dollars(0.0)
'$0.00'
Source code in src/ynaa_mcp/converters.py
def format_dollars(amount: float) -> str:
    """Format a dollar amount with $ symbol and comma separators.

    Args:
        amount: Dollar amount (already converted from milliunits).

    Returns:
        Formatted string like "$1,234.56" or "-$1,234.56".

    Examples:
        >>> format_dollars(1234.56)
        '$1,234.56'
        >>> format_dollars(-50.0)
        '-$50.00'
        >>> format_dollars(0.0)
        '$0.00'
    """
    if amount < 0:
        return f"-${abs(amount):,.2f}"
    return f"${amount:,.2f}"

normalize_month

normalize_month(month: str | None) -> str

Normalize a month parameter for the YNAB API.

Accepts "YYYY-MM", "YYYY-MM-DD", or None (current month).

Parameters:

  • month (str | None) –

    Month string or None for current month.

Returns:

  • str

    "current" or "YYYY-MM-01" format string.

Examples:

>>> normalize_month(None)
'current'
>>> normalize_month("2026-03")
'2026-03-01'
>>> normalize_month("2026-03-01")
'2026-03-01'
Source code in src/ynaa_mcp/converters.py
def normalize_month(month: str | None) -> str:
    """Normalize a month parameter for the YNAB API.

    Accepts ``"YYYY-MM"``, ``"YYYY-MM-DD"``, or ``None`` (current month).

    Args:
        month: Month string or None for current month.

    Returns:
        ``"current"`` or ``"YYYY-MM-01"`` format string.

    Examples:
        >>> normalize_month(None)
        'current'
        >>> normalize_month("2026-03")
        '2026-03-01'
        >>> normalize_month("2026-03-01")
        '2026-03-01'
    """
    if month is None:
        return "current"
    if len(month) == _YYYY_MM_LENGTH:
        return f"{month}-01"
    return month

_format_goal_lines

_format_goal_lines(cat: dict[str, Any]) -> list[str]

Build goal-info lines for a category detail view.

Parameters:

  • cat (dict[str, Any]) –

    Category dict from the YNAB API response.

Returns:

  • list[str]

    List of formatted goal lines, or empty list if no goal.

Source code in src/ynaa_mcp/tools/categories.py
def _format_goal_lines(cat: dict[str, Any]) -> list[str]:
    """Build goal-info lines for a category detail view.

    Args:
        cat: Category dict from the YNAB API response.

    Returns:
        List of formatted goal lines, or empty list if no goal.
    """
    if not cat.get("goal_type"):
        return []

    label = _GOAL_TYPE_LABELS.get(cat["goal_type"], cat["goal_type"])
    lines = [f"  Goal: {label}"]
    if cat.get("goal_target") is not None:
        lines.append(f"    Target: {format_dollars(cat['goal_target'])}")
    if cat.get("goal_target_month"):
        lines.append(f"    Target month: {cat['goal_target_month']}")
    if cat.get("goal_percentage_complete") is not None:
        lines.append(f"    Progress: {cat['goal_percentage_complete']}%")
    if cat.get("goal_months_to_budget") is not None:
        lines.append(f"    Months to budget: {cat['goal_months_to_budget']}")
    if cat.get("goal_under_funded") is not None:
        lines.append(f"    Under funded: {format_dollars(cat['goal_under_funded'])}")
    if cat.get("goal_overall_funded") is not None:
        lines.append(
            f"    Overall funded: {format_dollars(cat['goal_overall_funded'])}"
        )
    if cat.get("goal_overall_left") is not None:
        lines.append(f"    Overall left: {format_dollars(cat['goal_overall_left'])}")
    return lines

_list_categories async

_list_categories(
    app: AppContext, budget_id: str, info: str | None, *, include_hidden: bool = False
) -> str

List all categories grouped by category group.

Returns:

  • str

    Structured text with count header and indented hierarchy.

Source code in src/ynaa_mcp/tools/categories.py
async def _list_categories(
    app: AppContext,
    budget_id: str,
    info: str | None,
    *,
    include_hidden: bool = False,
) -> str:
    """List all categories grouped by category group.

    Returns:
        Structured text with count header and indented hierarchy.
    """
    data = await app.client.get(f"/budgets/{budget_id}/categories")
    groups = data["category_groups"]
    lines: list[str] = []
    total_count = 0
    for group in groups:
        if group["deleted"]:
            continue
        if group["hidden"] and not include_hidden:
            continue
        cats = [c for c in group["categories"] if not c["deleted"]]
        if not include_hidden:
            cats = [c for c in cats if not c["hidden"]]
        if not cats:
            continue
        total_count += len(cats)
        lines.append(f"\n{group['name']} (ID: {group['id']})")
        for cat in cats:
            budget_line = (
                f"    Budgeted: {format_dollars(cat['budgeted'])} | "
                f"Activity: {format_dollars(cat['activity'])} | "
                f"Balance: {format_dollars(cat['balance'])}"
            )
            lines.extend((
                f"  - {cat['name']}",
                f"    ID: {cat['id']}",
                budget_line,
            ))
    if total_count == 0:
        result = "No categories found."
        if info:
            result = f"{info}\n\n{result}"
        return result
    header = f"{total_count} categories found:"
    result = header + "\n".join(lines)
    if info:
        result = f"{info}\n\n{result}"
    return result

_get_category async

_get_category(
    app: AppContext, budget_id: str, info: str | None, *, category_id: str
) -> str

Get detailed information about a specific category.

Returns:

  • str

    Structured text with full category details.

Source code in src/ynaa_mcp/tools/categories.py
async def _get_category(
    app: AppContext,
    budget_id: str,
    info: str | None,
    *,
    category_id: str,
) -> str:
    """Get detailed information about a specific category.

    Returns:
        Structured text with full category details.
    """
    data = await app.client.get(f"/budgets/{budget_id}/categories/{category_id}")
    cat = data["category"]
    lines = [f"Category: {cat['name']}"]
    if cat.get("category_group_name"):
        lines.append(f"  Group: {cat['category_group_name']}")
    lines.extend((
        f"  Budgeted: {format_dollars(cat['budgeted'])}",
        f"  Activity: {format_dollars(cat['activity'])}",
        f"  Balance: {format_dollars(cat['balance'])}",
    ))
    if cat.get("note"):
        lines.append(f"  Note: {cat['note']}")
    lines.extend(_format_goal_lines(cat))
    result = "\n".join(lines)
    if info:
        result = f"{info}\n\n{result}"
    return result

_create_category async

_create_category(
    app: AppContext,
    budget_id: str,
    *,
    name: str,
    category_group_id: str | None = None,
    note: str | None = None,
    goal_target: float | None = None,
    goal_target_date: str | None = None,
) -> str

Create a new category.

Returns:

  • str

    Confirmation text with created category details.

Source code in src/ynaa_mcp/tools/categories.py
async def _create_category(  # noqa: PLR0913
    app: AppContext,
    budget_id: str,
    *,
    name: str,
    category_group_id: str | None = None,
    note: str | None = None,
    goal_target: float | None = None,
    goal_target_date: str | None = None,
) -> str:
    """Create a new category.

    Returns:
        Confirmation text with created category details.
    """
    body: dict[str, Any] = {"name": name}
    if category_group_id is not None:
        body["category_group_id"] = category_group_id
    if note is not None:
        body["note"] = note
    if goal_target is not None:
        body["goal_target"] = dollars_to_milliunits(goal_target)
    if goal_target_date is not None:
        body["goal_target_date"] = goal_target_date
    data = await app.client.post(
        f"/budgets/{budget_id}/categories",
        json={"category": body},
    )
    cat = data["category"]
    return f"Category created:\n  Name: {cat['name']}\n  ID: {cat['id']}"

_update_category async

_update_category(
    app: AppContext,
    budget_id: str,
    *,
    category_id: str,
    name: str | None = None,
    note: str | None = None,
    goal_target: float | None = None,
    goal_target_date: str | None = None,
) -> str

Update an existing category.

Returns:

  • str

    Confirmation text with updated category details.

Source code in src/ynaa_mcp/tools/categories.py
async def _update_category(  # noqa: PLR0913
    app: AppContext,
    budget_id: str,
    *,
    category_id: str,
    name: str | None = None,
    note: str | None = None,
    goal_target: float | None = None,
    goal_target_date: str | None = None,
) -> str:
    """Update an existing category.

    Returns:
        Confirmation text with updated category details.
    """
    body: dict[str, Any] = {}
    if name is not None:
        body["name"] = name
    if note is not None:
        body["note"] = note
    if goal_target is not None:
        body["goal_target"] = dollars_to_milliunits(goal_target)
    if goal_target_date is not None:
        body["goal_target_date"] = goal_target_date
    data = await app.client.patch(
        f"/budgets/{budget_id}/categories/{category_id}",
        json={"category": body},
    )
    cat = data["category"]
    return f"Category updated:\n  Name: {cat['name']}\n  ID: {cat['id']}"

_create_group async

_create_group(app: AppContext, budget_id: str, *, name: str) -> str

Create a new category group.

Returns:

  • str

    Confirmation text with created group details.

Raises:

  • ToolError

    If name exceeds 50 characters.

Source code in src/ynaa_mcp/tools/categories.py
async def _create_group(
    app: AppContext,
    budget_id: str,
    *,
    name: str,
) -> str:
    """Create a new category group.

    Returns:
        Confirmation text with created group details.

    Raises:
        ToolError: If name exceeds 50 characters.
    """
    if len(name) > _MAX_GROUP_NAME_LENGTH:
        msg = (
            f"Category group name must be {_MAX_GROUP_NAME_LENGTH} "
            f"characters or fewer (got {len(name)})."
        )
        raise ToolError(msg)
    data = await app.client.post(
        f"/budgets/{budget_id}/category_groups",
        json={"category_group": {"name": name}},
    )
    group = data["category_group"]
    return f"Category group created:\n  Name: {group['name']}\n  ID: {group['id']}"

_update_group async

_update_group(
    app: AppContext, budget_id: str, *, category_group_id: str, name: str
) -> str

Update an existing category group.

Returns:

  • str

    Confirmation text with updated group details.

Raises:

  • ToolError

    If name exceeds 50 characters.

Source code in src/ynaa_mcp/tools/categories.py
async def _update_group(
    app: AppContext,
    budget_id: str,
    *,
    category_group_id: str,
    name: str,
) -> str:
    """Update an existing category group.

    Returns:
        Confirmation text with updated group details.

    Raises:
        ToolError: If name exceeds 50 characters.
    """
    if len(name) > _MAX_GROUP_NAME_LENGTH:
        msg = (
            f"Category group name must be {_MAX_GROUP_NAME_LENGTH} "
            f"characters or fewer (got {len(name)})."
        )
        raise ToolError(msg)
    data = await app.client.patch(
        f"/budgets/{budget_id}/category_groups/{category_group_id}",
        json={"category_group": {"name": name}},
    )
    group = data["category_group"]
    return f"Category group updated:\n  Name: {group['name']}\n  ID: {group['id']}"

_set_month_budget async

_set_month_budget(
    app: AppContext,
    budget_id: str,
    *,
    category_id: str,
    month: str | None = None,
    budgeted: float | None = None,
) -> str

Get or update the budgeted amount for a category in a specific month.

Returns:

  • str

    Structured text with category budget details or confirmation.

Source code in src/ynaa_mcp/tools/categories.py
async def _set_month_budget(
    app: AppContext,
    budget_id: str,
    *,
    category_id: str,
    month: str | None = None,
    budgeted: float | None = None,
) -> str:
    """Get or update the budgeted amount for a category in a specific month.

    Returns:
        Structured text with category budget details or confirmation.
    """
    normalized = normalize_month(month)
    path = f"/budgets/{budget_id}/months/{normalized}/categories/{category_id}"
    if budgeted is None:
        data = await app.client.get(path)
        cat = data["category"]
        lines = [
            f"Category: {cat['name']}",
            f"  Month: {normalized}",
            f"  Budgeted: {format_dollars(cat['budgeted'])}",
            f"  Activity: {format_dollars(cat['activity'])}",
            f"  Balance: {format_dollars(cat['balance'])}",
        ]
        lines.extend(_format_goal_lines(cat))
        return "\n".join(lines)
    milliunits = dollars_to_milliunits(budgeted)
    data = await app.client.patch(
        path,
        json={"category": {"budgeted": milliunits}},
    )
    cat = data["category"]
    return (
        f"Category budget updated:\n"
        f"  Category: {cat['name']}\n"
        f"  Month: {normalized}\n"
        f"  Budgeted: {format_dollars(budgeted)}"
    )

manage_categories async

manage_categories(
    ctx: Context,
    action: Literal[
        "list",
        "get",
        "create",
        "update",
        "create_group",
        "update_group",
        "set_month_budget",
    ],
    budget_id_or_name: str | None = None,
    include_hidden: bool = False,
    category_id: str | None = None,
    category_group_id: str | None = None,
    name: str | None = None,
    note: str | None = None,
    goal_target: float | None = None,
    goal_target_date: str | None = None,
    month: str | None = None,
    budgeted: float | None = None,
) -> str

Manage YNAB categories: list, get, create, update, and budget by month.

Actions

list: List all categories. Uses budget_id_or_name, include_hidden. get: Get category details. Uses category_id (required). create: Create category. Uses name (required), category_group_id, note, goal_target, goal_target_date. update: Update category. Uses category_id (required), name, note, goal_target, goal_target_date. create_group: Create category group. Uses name (required). update_group: Update category group. Uses category_group_id (required), name (required). set_month_budget: Get or set month budget. Uses category_id (required), month, budgeted (omit to get current value).

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'create', 'update', 'create_group', 'update_group', 'set_month_budget']) –

    The operation to perform.

  • budget_id_or_name (str | None, default: None ) –

    Budget UUID or name. Auto-resolves if only one budget exists.

  • include_hidden (bool, default: False ) –

    If True, include hidden categories (list only).

  • category_id (str | None, default: None ) –

    The category UUID (get, update, set_month_budget).

  • category_group_id (str | None, default: None ) –

    Category group UUID (create, update_group).

  • name (str | None, default: None ) –

    Name for category or group.

  • note (str | None, default: None ) –

    Category note (create, update).

  • goal_target (float | None, default: None ) –

    Goal target in dollars (create, update).

  • goal_target_date (str | None, default: None ) –

    Goal target date (create, update).

  • month (str | None, default: None ) –

    Month as YYYY-MM or YYYY-MM-DD (set_month_budget).

  • budgeted (float | None, default: None ) –

    Budgeted amount in dollars (set_month_budget).

Returns:

  • str

    Structured text with category information or confirmation.

Raises:

  • ToolError

    If required parameters for the action are missing.

Source code in src/ynaa_mcp/tools/categories.py
@mcp.tool
async def manage_categories(  # noqa: PLR0913, PLR0917, C901, PLR0911
    ctx: Context,
    action: Literal[
        "list",
        "get",
        "create",
        "update",
        "create_group",
        "update_group",
        "set_month_budget",
    ],
    budget_id_or_name: str | None = None,
    include_hidden: bool = False,  # noqa: FBT001, FBT002
    category_id: str | None = None,
    category_group_id: str | None = None,
    name: str | None = None,
    note: str | None = None,
    goal_target: float | None = None,
    goal_target_date: str | None = None,
    month: str | None = None,
    budgeted: float | None = None,
) -> str:
    """Manage YNAB categories: list, get, create, update, and budget by month.

    Actions:
        list: List all categories. Uses budget_id_or_name, include_hidden.
        get: Get category details. Uses category_id (required).
        create: Create category. Uses name (required), category_group_id, note,
            goal_target, goal_target_date.
        update: Update category. Uses category_id (required), name, note,
            goal_target, goal_target_date.
        create_group: Create category group. Uses name (required).
        update_group: Update category group. Uses category_group_id (required),
            name (required).
        set_month_budget: Get or set month budget. Uses category_id (required),
            month, budgeted (omit to get current value).

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name. Auto-resolves if only
            one budget exists.
        include_hidden: If True, include hidden categories (list only).
        category_id: The category UUID (get, update, set_month_budget).
        category_group_id: Category group UUID (create, update_group).
        name: Name for category or group.
        note: Category note (create, update).
        goal_target: Goal target in dollars (create, update).
        goal_target_date: Goal target date (create, update).
        month: Month as YYYY-MM or YYYY-MM-DD (set_month_budget).
        budgeted: Budgeted amount in dollars (set_month_budget).

    Returns:
        Structured text with category information or confirmation.

    Raises:
        ToolError: If required parameters for the action are missing.
    """
    app = cast("AppContext", ctx.lifespan_context)
    budget_id, info = await resolve_budget(
        app.client, budget_id_or_name, cache=app.cache
    )

    if action == "list":
        return await _list_categories(
            app, budget_id, info, include_hidden=include_hidden
        )
    if action == "get":
        if category_id is None:
            msg = "category_id is required for action='get'"
            raise ToolError(msg)
        return await _get_category(app, budget_id, info, category_id=category_id)
    if action == "create":
        if name is None:
            msg = "name is required for action='create'"
            raise ToolError(msg)
        return await _create_category(
            app,
            budget_id,
            name=name,
            category_group_id=category_group_id,
            note=note,
            goal_target=goal_target,
            goal_target_date=goal_target_date,
        )
    if action == "update":
        if category_id is None:
            msg = "category_id is required for action='update'"
            raise ToolError(msg)
        return await _update_category(
            app,
            budget_id,
            category_id=category_id,
            name=name,
            note=note,
            goal_target=goal_target,
            goal_target_date=goal_target_date,
        )
    if action == "create_group":
        if name is None:
            msg = "name is required for action='create_group'"
            raise ToolError(msg)
        return await _create_group(app, budget_id, name=name)
    if action == "update_group":
        if category_group_id is None or name is None:
            msg = "category_group_id and name are required for action='update_group'"
            raise ToolError(msg)
        return await _update_group(
            app, budget_id, category_group_id=category_group_id, name=name
        )
    if category_id is None:
        msg = "category_id is required for action='set_month_budget'"
        raise ToolError(msg)
    return await _set_month_budget(
        app, budget_id, category_id=category_id, month=month, budgeted=budgeted
    )

Transactions

transactions

Transaction tools: consolidated manage_transactions dispatch.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

_CLEARED_INDICATORS module-attribute

_CLEARED_INDICATORS: dict[str, str] = {
    "cleared": "[C]",
    "uncleared": "[U]",
    "reconciled": "[R]",
}

Compact status indicators for transaction list view.

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

dollars_to_milliunits

dollars_to_milliunits(dollars: float) -> int

Convert a dollar amount to YNAB milliunits.

Uses Decimal(str(dollars)) to avoid floating-point precision issues, then rounds to the nearest milliunit using ROUND_HALF_UP.

Parameters:

  • dollars (float) –

    Dollar amount to convert.

Returns:

  • int

    The equivalent amount in YNAB milliunits as an integer.

Examples:

>>> dollars_to_milliunits(45.67)
45670
>>> dollars_to_milliunits(0.1 + 0.2)
300
Source code in src/ynaa_mcp/converters.py
def dollars_to_milliunits(dollars: float) -> int:
    """Convert a dollar amount to YNAB milliunits.

    Uses ``Decimal(str(dollars))`` to avoid floating-point precision issues,
    then rounds to the nearest milliunit using ROUND_HALF_UP.

    Args:
        dollars: Dollar amount to convert.

    Returns:
        The equivalent amount in YNAB milliunits as an integer.

    Examples:
        >>> dollars_to_milliunits(45.67)
        45670
        >>> dollars_to_milliunits(0.1 + 0.2)
        300
    """
    result = Decimal(str(dollars)) * MILLIUNIT_FACTOR
    return int(result.quantize(Decimal(1), rounding=ROUND_HALF_UP))

format_dollars

format_dollars(amount: float) -> str

Format a dollar amount with $ symbol and comma separators.

Parameters:

  • amount (float) –

    Dollar amount (already converted from milliunits).

Returns:

  • str

    Formatted string like "$1,234.56" or "-$1,234.56".

Examples:

>>> format_dollars(1234.56)
'$1,234.56'
>>> format_dollars(-50.0)
'-$50.00'
>>> format_dollars(0.0)
'$0.00'
Source code in src/ynaa_mcp/converters.py
def format_dollars(amount: float) -> str:
    """Format a dollar amount with $ symbol and comma separators.

    Args:
        amount: Dollar amount (already converted from milliunits).

    Returns:
        Formatted string like "$1,234.56" or "-$1,234.56".

    Examples:
        >>> format_dollars(1234.56)
        '$1,234.56'
        >>> format_dollars(-50.0)
        '-$50.00'
        >>> format_dollars(0.0)
        '$0.00'
    """
    if amount < 0:
        return f"-${abs(amount):,.2f}"
    return f"${amount:,.2f}"

normalize_month

normalize_month(month: str | None) -> str

Normalize a month parameter for the YNAB API.

Accepts "YYYY-MM", "YYYY-MM-DD", or None (current month).

Parameters:

  • month (str | None) –

    Month string or None for current month.

Returns:

  • str

    "current" or "YYYY-MM-01" format string.

Examples:

>>> normalize_month(None)
'current'
>>> normalize_month("2026-03")
'2026-03-01'
>>> normalize_month("2026-03-01")
'2026-03-01'
Source code in src/ynaa_mcp/converters.py
def normalize_month(month: str | None) -> str:
    """Normalize a month parameter for the YNAB API.

    Accepts ``"YYYY-MM"``, ``"YYYY-MM-DD"``, or ``None`` (current month).

    Args:
        month: Month string or None for current month.

    Returns:
        ``"current"`` or ``"YYYY-MM-01"`` format string.

    Examples:
        >>> normalize_month(None)
        'current'
        >>> normalize_month("2026-03")
        '2026-03-01'
        >>> normalize_month("2026-03-01")
        '2026-03-01'
    """
    if month is None:
        return "current"
    if len(month) == _YYYY_MM_LENGTH:
        return f"{month}-01"
    return month

_format_transaction_line

_format_transaction_line(txn: dict[str, Any]) -> list[str]

Format a single transaction for list view.

Each transaction produces two lines: a summary line with date, payee, amount, category, and cleared status, followed by the transaction ID.

Parameters:

  • txn (dict[str, Any]) –

    Transaction dict from the YNAB API response.

Returns:

  • list[str]

    Two-element list: summary line and ID line.

Source code in src/ynaa_mcp/tools/transactions.py
def _format_transaction_line(txn: dict[str, Any]) -> list[str]:
    """Format a single transaction for list view.

    Each transaction produces two lines: a summary line with date, payee,
    amount, category, and cleared status, followed by the transaction ID.

    Args:
        txn: Transaction dict from the YNAB API response.

    Returns:
        Two-element list: summary line and ID line.
    """
    status = _CLEARED_INDICATORS.get(txn.get("cleared", ""), "")
    payee = txn.get("payee_name") or "(no payee)"
    category = txn.get("category_name") or "(no category)"
    amount = format_dollars(txn["amount"])
    return [
        f"- {txn['date']} | {payee} | {amount} | {category} {status}",
        f"  ID: {txn['id']}",
    ]

_format_transaction_detail

_format_transaction_detail(txn: dict[str, Any]) -> list[str]

Format a single transaction for detail view.

Includes all fields with optional ones only shown when present. Subtransactions are displayed as an indented list.

Parameters:

  • txn (dict[str, Any]) –

    Transaction dict from the YNAB API response.

Returns:

  • list[str]

    List of formatted lines for the detail view.

Source code in src/ynaa_mcp/tools/transactions.py
def _format_transaction_detail(txn: dict[str, Any]) -> list[str]:
    """Format a single transaction for detail view.

    Includes all fields with optional ones only shown when present.
    Subtransactions are displayed as an indented list.

    Args:
        txn: Transaction dict from the YNAB API response.

    Returns:
        List of formatted lines for the detail view.
    """
    lines = [
        f"Transaction: {txn.get('payee_name') or '(no payee)'}",
        f"  ID: {txn['id']}",
        f"  Date: {txn['date']}",
        f"  Amount: {format_dollars(txn['amount'])}",
        f"  Account: {txn['account_name']}",
        f"  Category: {txn.get('category_name') or '(none)'}",
        f"  Status: {txn['cleared']}",
        f"  Approved: {'Yes' if txn['approved'] else 'No'}",
    ]
    if txn.get("memo"):
        lines.append(f"  Memo: {txn['memo']}")
    if txn.get("flag_color"):
        lines.append(f"  Flag: {txn['flag_color']}")
    if txn.get("transfer_account_id"):
        lines.append(f"  Transfer account: {txn['transfer_account_id']}")

    subtxns = txn.get("subtransactions", [])
    if subtxns:
        lines.append(f"  Split ({len(subtxns)} items):")
        for sub in subtxns:
            sub_cat = sub.get("category_name") or "(no category)"
            lines.append(f"    - {format_dollars(sub['amount'])} | {sub_cat}")
            if sub.get("memo"):
                lines.append(f"      Memo: {sub['memo']}")
    return lines

_format_transaction_confirmation

_format_transaction_confirmation(verb: str, txn: dict[str, Any]) -> str

Format a transaction create/update/delete confirmation.

Parameters:

  • verb (str) –

    Action word ("created", "updated", "deleted").

  • txn (dict[str, Any]) –

    Transaction dict from the YNAB API response.

Returns:

  • str

    Confirmation string with key transaction fields.

Source code in src/ynaa_mcp/tools/transactions.py
def _format_transaction_confirmation(verb: str, txn: dict[str, Any]) -> str:
    """Format a transaction create/update/delete confirmation.

    Args:
        verb: Action word ("created", "updated", "deleted").
        txn: Transaction dict from the YNAB API response.

    Returns:
        Confirmation string with key transaction fields.
    """
    payee = txn.get("payee_name") or "(no payee)"
    category = txn.get("category_name") or "(no category)"
    lines = [
        f"Transaction {verb}:",
        f"  ID: {txn['id']}",
        f"  Date: {txn['date']}",
        f"  Payee: {payee}",
        f"  Amount: {format_dollars(txn['amount'])}",
        f"  Category: {category}",
    ]
    return "\n".join(lines)

_format_batch_result

_format_batch_result(data: dict[str, Any], verb: str) -> str

Format a batch transaction create/update response.

Parameters:

  • data (dict[str, Any]) –

    Response dict from the YNAB API with transaction_ids and optionally duplicate_import_ids.

  • verb (str) –

    Action word ("created" or "updated").

Returns:

  • str

    Summary string with count header, per-ID lines, and duplicate IDs.

Source code in src/ynaa_mcp/tools/transactions.py
def _format_batch_result(data: dict[str, Any], verb: str) -> str:
    """Format a batch transaction create/update response.

    Args:
        data: Response dict from the YNAB API with transaction_ids
            and optionally duplicate_import_ids.
        verb: Action word ("created" or "updated").

    Returns:
        Summary string with count header, per-ID lines, and duplicate IDs.
    """
    txn_ids = data.get("transaction_ids", [])
    count = len(txn_ids)
    noun = "transaction" if count == 1 else "transactions"
    lines = [f"{count} {noun} {verb}:"]
    lines.extend(f"  - {txn_id}" for txn_id in txn_ids)

    dup_ids = data.get("duplicate_import_ids", [])
    if dup_ids:
        lines.append(f"\n{len(dup_ids)} duplicate(s) skipped:")
        lines.extend(f"  - {dup_id}" for dup_id in dup_ids)

    return "\n".join(lines)

_list_transactions async

_list_transactions(
    app: AppContext,
    budget_id: str,
    since_date: str | None,
    until_date: str | None,
    type: str | None,
    account_id: str | None,
    category_id: str | None,
    payee_id: str | None,
    month: str | None,
    limit: int | None,
) -> str

List transactions with optional filtering.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • since_date (str | None) –

    Only return transactions on or after this date.

  • until_date (str | None) –

    Only return transactions on or before this date.

  • type (str | None) –

    Filter by transaction type.

  • account_id (str | None) –

    Filter by account.

  • category_id (str | None) –

    Filter by category.

  • payee_id (str | None) –

    Filter by payee.

  • month (str | None) –

    Filter by month.

  • limit (int | None) –

    Maximum number of transactions to return.

Returns:

  • str

    Structured text with transaction listings.

Raises:

  • ToolError

    If more than one filter param is provided.

Source code in src/ynaa_mcp/tools/transactions.py
async def _list_transactions(  # noqa: PLR0913, PLR0917, PLR0912, C901
    app: AppContext,
    budget_id: str,
    since_date: str | None,
    until_date: str | None,
    type: str | None,  # noqa: A002
    account_id: str | None,
    category_id: str | None,
    payee_id: str | None,
    month: str | None,
    limit: int | None,
) -> str:
    """List transactions with optional filtering.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        since_date: Only return transactions on or after this date.
        until_date: Only return transactions on or before this date.
        type: Filter by transaction type.
        account_id: Filter by account.
        category_id: Filter by category.
        payee_id: Filter by payee.
        month: Filter by month.
        limit: Maximum number of transactions to return.

    Returns:
        Structured text with transaction listings.

    Raises:
        ToolError: If more than one filter param is provided.
    """
    filters = [account_id, category_id, payee_id, month]
    active_filters = sum(1 for f in filters if f is not None)
    if active_filters > 1:
        msg = (
            "Only one filter (account, category, payee, or month) "
            "can be used at a time."
        )
        raise ToolError(msg)

    if account_id:
        path = f"/budgets/{budget_id}/accounts/{account_id}/transactions"
    elif category_id:
        path = f"/budgets/{budget_id}/categories/{category_id}/transactions"
    elif payee_id:
        path = f"/budgets/{budget_id}/payees/{payee_id}/transactions"
    elif month:
        normalized = normalize_month(month)
        path = f"/budgets/{budget_id}/months/{normalized}/transactions"
    else:
        path = f"/budgets/{budget_id}/transactions"

    params: dict[str, str] = {}
    if since_date:
        params["since_date"] = since_date
    if type:
        params["type"] = type

    data = await app.client.get(path, params=params)
    transactions = data["transactions"]

    if until_date:
        transactions = [t for t in transactions if t["date"] <= until_date]

    if not transactions:
        return "No transactions found."

    total = len(transactions)

    if limit and total > limit:
        header = f"Showing {limit} of {total} transactions:"
        transactions = transactions[:limit]
    else:
        noun = "transaction" if total == 1 else "transactions"
        header = f"{total} {noun} found:"

    lines = [header]
    for txn in transactions:
        lines.extend(_format_transaction_line(txn))

    return "\n".join(lines)

_get_transaction async

_get_transaction(app: AppContext, budget_id: str, transaction_id: str) -> str

Get detailed information about a specific transaction.

Returns:

  • str

    Structured text with full transaction detail view.

Source code in src/ynaa_mcp/tools/transactions.py
async def _get_transaction(
    app: AppContext,
    budget_id: str,
    transaction_id: str,
) -> str:
    """Get detailed information about a specific transaction.

    Returns:
        Structured text with full transaction detail view.
    """
    data = await app.client.get(f"/budgets/{budget_id}/transactions/{transaction_id}")
    txn = data["transaction"]
    lines = _format_transaction_detail(txn)
    return "\n".join(lines)

_create_transaction async

_create_transaction(
    app: AppContext,
    budget_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    cleared: str | None,
    approved: bool | None,
    flag_color: str | None,
) -> str

Create a new transaction.

Returns:

  • str

    Confirmation text.

Raises:

  • ToolError

    If required fields are missing.

Source code in src/ynaa_mcp/tools/transactions.py
async def _create_transaction(  # noqa: PLR0913, PLR0917, C901
    app: AppContext,
    budget_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    cleared: str | None,
    approved: bool | None,  # noqa: FBT001
    flag_color: str | None,
) -> str:
    """Create a new transaction.

    Returns:
        Confirmation text.

    Raises:
        ToolError: If required fields are missing.
    """
    missing: list[str] = []
    if account_id is None:
        missing.append("account_id")
    if date is None:
        missing.append("date")
    if amount is None:
        missing.append("amount")
    if missing:
        msg = f"Create requires: {', '.join(missing)}"
        raise ToolError(msg)

    # Narrowing: pyright can't track list-based None checks, assert after guard
    assert account_id is not None
    assert date is not None
    assert amount is not None

    optional_fields: dict[str, Any] = {}
    if payee_name is not None:
        optional_fields["payee_name"] = payee_name
    if payee_id is not None:
        optional_fields["payee_id"] = payee_id
    if category_id is not None:
        optional_fields["category_id"] = category_id
    if memo is not None:
        optional_fields["memo"] = memo
    if cleared is not None:
        optional_fields["cleared"] = cleared
    if approved is not None:
        optional_fields["approved"] = approved
    if flag_color is not None:
        optional_fields["flag_color"] = flag_color

    body: dict[str, Any] = {
        "account_id": account_id,
        "date": date,
        "amount": dollars_to_milliunits(amount),
        **optional_fields,
    }

    data = await app.client.post(
        f"/budgets/{budget_id}/transactions",
        json={"transaction": body},
    )
    txn = data["transaction"]
    return _format_transaction_confirmation("created", txn)

_update_transaction async

_update_transaction(
    app: AppContext,
    budget_id: str,
    transaction_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    cleared: str | None,
    approved: bool | None,
    flag_color: str | None,
) -> str

Update an existing transaction.

Returns:

  • str

    Confirmation text.

Source code in src/ynaa_mcp/tools/transactions.py
async def _update_transaction(  # noqa: PLR0913, PLR0917, C901
    app: AppContext,
    budget_id: str,
    transaction_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    cleared: str | None,
    approved: bool | None,  # noqa: FBT001
    flag_color: str | None,
) -> str:
    """Update an existing transaction.

    Returns:
        Confirmation text.
    """
    optional_fields: dict[str, Any] = {}
    if payee_name is not None:
        optional_fields["payee_name"] = payee_name
    if payee_id is not None:
        optional_fields["payee_id"] = payee_id
    if category_id is not None:
        optional_fields["category_id"] = category_id
    if memo is not None:
        optional_fields["memo"] = memo
    if cleared is not None:
        optional_fields["cleared"] = cleared
    if approved is not None:
        optional_fields["approved"] = approved
    if flag_color is not None:
        optional_fields["flag_color"] = flag_color

    body: dict[str, Any] = {**optional_fields}
    if amount is not None:
        body["amount"] = dollars_to_milliunits(amount)
    if date is not None:
        body["date"] = date
    if account_id is not None:
        body["account_id"] = account_id

    data = await app.client.put(
        f"/budgets/{budget_id}/transactions/{transaction_id}",
        json={"transaction": body},
    )
    txn = data["transaction"]
    return _format_transaction_confirmation("updated", txn)

_delete_transaction async

_delete_transaction(app: AppContext, budget_id: str, transaction_id: str) -> str

Delete a transaction.

Returns:

  • str

    Confirmation text.

Source code in src/ynaa_mcp/tools/transactions.py
async def _delete_transaction(
    app: AppContext,
    budget_id: str,
    transaction_id: str,
) -> str:
    """Delete a transaction.

    Returns:
        Confirmation text.
    """
    data = await app.client.delete(
        f"/budgets/{budget_id}/transactions/{transaction_id}",
    )
    txn = data["transaction"]
    return _format_transaction_confirmation("deleted", txn)

_batch_create_transactions async

_batch_create_transactions(
    app: AppContext, budget_id: str, transactions: list[dict[str, Any]] | None
) -> str

Create multiple transactions in a single API call.

Returns:

  • str

    Summary with count of created transactions and their IDs.

Raises:

  • ToolError

    If transactions list is empty or None.

Source code in src/ynaa_mcp/tools/transactions.py
async def _batch_create_transactions(
    app: AppContext,
    budget_id: str,
    transactions: list[dict[str, Any]] | None,
) -> str:
    """Create multiple transactions in a single API call.

    Returns:
        Summary with count of created transactions and their IDs.

    Raises:
        ToolError: If transactions list is empty or None.
    """
    if not transactions:
        msg = "Transactions list must not be empty."
        raise ToolError(msg)

    processed: list[dict[str, Any]] = []
    for txn in transactions:
        txn_copy = dict(txn)
        if "amount" in txn_copy:
            txn_copy["amount"] = dollars_to_milliunits(txn_copy["amount"])
        processed.append(txn_copy)

    data = await app.client.post(
        f"/budgets/{budget_id}/transactions",
        json={"transactions": processed},
    )
    return _format_batch_result(data, "created")

_batch_update_transactions async

_batch_update_transactions(
    app: AppContext, budget_id: str, transactions: list[dict[str, Any]] | None
) -> str

Update multiple transactions in a single API call.

Returns:

  • str

    Summary with count of updated transactions and their IDs.

Raises:

  • ToolError

    If transactions list is empty or None.

Source code in src/ynaa_mcp/tools/transactions.py
async def _batch_update_transactions(
    app: AppContext,
    budget_id: str,
    transactions: list[dict[str, Any]] | None,
) -> str:
    """Update multiple transactions in a single API call.

    Returns:
        Summary with count of updated transactions and their IDs.

    Raises:
        ToolError: If transactions list is empty or None.
    """
    if not transactions:
        msg = "Transactions list must not be empty."
        raise ToolError(msg)

    processed: list[dict[str, Any]] = []
    for txn in transactions:
        txn_copy = dict(txn)
        if "amount" in txn_copy:
            txn_copy["amount"] = dollars_to_milliunits(txn_copy["amount"])
        processed.append(txn_copy)

    data = await app.client.patch(
        f"/budgets/{budget_id}/transactions",
        json={"transactions": processed},
    )
    return _format_batch_result(data, "updated")

_import_transactions async

_import_transactions(app: AppContext, budget_id: str) -> str

Trigger import of transactions from linked accounts.

Returns:

  • str

    Summary of imported transactions, or message if none imported.

Source code in src/ynaa_mcp/tools/transactions.py
async def _import_transactions(app: AppContext, budget_id: str) -> str:
    """Trigger import of transactions from linked accounts.

    Returns:
        Summary of imported transactions, or message if none imported.
    """
    data = await app.client.post(f"/budgets/{budget_id}/transactions/import")
    txn_ids = data.get("transaction_ids", [])

    if not txn_ids:
        return "No transactions to import."

    count = len(txn_ids)
    noun = "transaction" if count == 1 else "transactions"
    lines = [f"{count} {noun} imported:"]
    lines.extend(f"  - {txn_id}" for txn_id in txn_ids)
    return "\n".join(lines)

manage_transactions async

manage_transactions(
    ctx: Context,
    action: Literal[
        "list",
        "get",
        "create",
        "update",
        "delete",
        "batch_create",
        "batch_update",
        "import",
    ],
    budget_id_or_name: str = "last-used",
    transaction_id: str | None = None,
    account_id: str | None = None,
    date: str | None = None,
    amount: float | None = None,
    payee_name: str | None = None,
    payee_id: str | None = None,
    category_id: str | None = None,
    memo: str | None = None,
    cleared: str | None = None,
    approved: bool | None = None,
    flag_color: str | None = None,
    since_date: str | None = None,
    until_date: str | None = None,
    type: str | None = None,
    month: str | None = None,
    limit: int | None = None,
    transactions: list[dict[str, Any]] | None = None,
) -> str

Manage YNAB transactions: list, get, create, update, delete, batch, import.

Dispatches to the appropriate action based on the action parameter.

Actions

list: List transactions with optional filtering. Params: budget_id_or_name, since_date, until_date, type, account_id, category_id, payee_id, month, limit. Only one of account_id/category_id/payee_id/month at a time. get: Get full detail for a transaction. Params: budget_id_or_name, transaction_id (required). create: Create a new transaction. Params: budget_id_or_name, account_id (required), date (required), amount (required), payee_name, payee_id, category_id, memo, cleared, approved, flag_color. update: Update an existing transaction. Params: budget_id_or_name, transaction_id (required), plus any optional fields to change. delete: Delete a transaction. Params: budget_id_or_name, transaction_id (required). batch_create: Create multiple transactions at once. Params: budget_id_or_name, transactions (required, list[dict]). batch_update: Update multiple transactions at once. Params: budget_id_or_name, transactions (required, list[dict]). import: Import transactions from linked bank accounts. Params: budget_id_or_name only.

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'create', 'update', 'delete', 'batch_create', 'batch_update', 'import']) –

    The operation to perform.

  • budget_id_or_name (str, default: 'last-used' ) –

    Budget UUID or name. Defaults to "last-used".

  • transaction_id (str | None, default: None ) –

    Transaction UUID (required for get, update, delete).

  • account_id (str | None, default: None ) –

    Account UUID (required for create, filter for list).

  • date (str | None, default: None ) –

    Transaction date as ISO string (required for create).

  • amount (float | None, default: None ) –

    Amount in dollars (required for create, converted to milliunits).

  • payee_name (str | None, default: None ) –

    Payee display name.

  • payee_id (str | None, default: None ) –

    Payee UUID (filter for list, field for create/update).

  • category_id (str | None, default: None ) –

    Category UUID (filter for list, field for create/update).

  • memo (str | None, default: None ) –

    Transaction memo.

  • cleared (str | None, default: None ) –

    Cleared status ("cleared", "uncleared", "reconciled").

  • approved (bool | None, default: None ) –

    Whether the transaction is approved.

  • flag_color (str | None, default: None ) –

    Flag color for the transaction.

  • since_date (str | None, default: None ) –

    Only return transactions on or after this date (list only).

  • until_date (str | None, default: None ) –

    Only return transactions on or before this date (list only).

  • type (str | None, default: None ) –

    Filter by type ("uncategorized" or "unapproved", list only).

  • month (str | None, default: None ) –

    Filter by month (list only, "YYYY-MM" or "YYYY-MM-DD").

  • limit (int | None, default: None ) –

    Maximum number of transactions to return (list only).

  • transactions (list[dict[str, Any]] | None, default: None ) –

    List of transaction dicts (batch_create/batch_update only).

Returns:

  • str

    Structured text with the requested transaction data.

Raises:

  • ToolError

    If required parameters for the action are missing.

Source code in src/ynaa_mcp/tools/transactions.py
@mcp.tool
async def manage_transactions(  # noqa: PLR0913, PLR0917, C901, PLR0911
    ctx: Context,
    action: Literal[
        "list",
        "get",
        "create",
        "update",
        "delete",
        "batch_create",
        "batch_update",
        "import",
    ],
    budget_id_or_name: str = "last-used",
    transaction_id: str | None = None,
    account_id: str | None = None,
    date: str | None = None,
    amount: float | None = None,
    payee_name: str | None = None,
    payee_id: str | None = None,
    category_id: str | None = None,
    memo: str | None = None,
    cleared: str | None = None,
    approved: bool | None = None,  # noqa: FBT001
    flag_color: str | None = None,
    since_date: str | None = None,
    until_date: str | None = None,
    type: str | None = None,  # noqa: A002
    month: str | None = None,
    limit: int | None = None,
    transactions: list[dict[str, Any]] | None = None,
) -> str:
    """Manage YNAB transactions: list, get, create, update, delete, batch, import.

    Dispatches to the appropriate action based on the ``action`` parameter.

    Actions:
        list: List transactions with optional filtering.
            Params: budget_id_or_name, since_date, until_date, type,
            account_id, category_id, payee_id, month, limit.
            Only one of account_id/category_id/payee_id/month at a time.
        get: Get full detail for a transaction.
            Params: budget_id_or_name, transaction_id (required).
        create: Create a new transaction.
            Params: budget_id_or_name, account_id (required), date (required),
            amount (required), payee_name, payee_id, category_id, memo,
            cleared, approved, flag_color.
        update: Update an existing transaction.
            Params: budget_id_or_name, transaction_id (required),
            plus any optional fields to change.
        delete: Delete a transaction.
            Params: budget_id_or_name, transaction_id (required).
        batch_create: Create multiple transactions at once.
            Params: budget_id_or_name, transactions (required, list[dict]).
        batch_update: Update multiple transactions at once.
            Params: budget_id_or_name, transactions (required, list[dict]).
        import: Import transactions from linked bank accounts.
            Params: budget_id_or_name only.

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name. Defaults to "last-used".
        transaction_id: Transaction UUID (required for get, update, delete).
        account_id: Account UUID (required for create, filter for list).
        date: Transaction date as ISO string (required for create).
        amount: Amount in dollars (required for create, converted to milliunits).
        payee_name: Payee display name.
        payee_id: Payee UUID (filter for list, field for create/update).
        category_id: Category UUID (filter for list, field for create/update).
        memo: Transaction memo.
        cleared: Cleared status ("cleared", "uncleared", "reconciled").
        approved: Whether the transaction is approved.
        flag_color: Flag color for the transaction.
        since_date: Only return transactions on or after this date (list only).
        until_date: Only return transactions on or before this date (list only).
        type: Filter by type ("uncategorized" or "unapproved", list only).
        month: Filter by month (list only, "YYYY-MM" or "YYYY-MM-DD").
        limit: Maximum number of transactions to return (list only).
        transactions: List of transaction dicts (batch_create/batch_update only).

    Returns:
        Structured text with the requested transaction data.

    Raises:
        ToolError: If required parameters for the action are missing.
    """
    app = cast("AppContext", ctx.lifespan_context)
    budget_id, _info = await resolve_budget(
        app.client, budget_id_or_name, cache=app.cache
    )

    if action == "list":
        return await _list_transactions(
            app,
            budget_id,
            since_date,
            until_date,
            type,
            account_id,
            category_id,
            payee_id,
            month,
            limit,
        )

    if action == "get":
        if transaction_id is None:
            msg = "action='get' requires 'transaction_id'"
            raise ToolError(msg)
        return await _get_transaction(app, budget_id, transaction_id)

    if action == "create":
        return await _create_transaction(
            app,
            budget_id,
            account_id,
            date,
            amount,
            payee_name,
            payee_id,
            category_id,
            memo,
            cleared,
            approved,
            flag_color,
        )

    if action == "update":
        if transaction_id is None:
            msg = "action='update' requires 'transaction_id'"
            raise ToolError(msg)
        return await _update_transaction(
            app,
            budget_id,
            transaction_id,
            account_id,
            date,
            amount,
            payee_name,
            payee_id,
            category_id,
            memo,
            cleared,
            approved,
            flag_color,
        )

    if action == "delete":
        if transaction_id is None:
            msg = "action='delete' requires 'transaction_id'"
            raise ToolError(msg)
        return await _delete_transaction(app, budget_id, transaction_id)

    if action == "batch_create":
        return await _batch_create_transactions(app, budget_id, transactions)

    if action == "batch_update":
        return await _batch_update_transactions(app, budget_id, transactions)

    # Last action: import
    return await _import_transactions(app, budget_id)

Payees

payees

Payee tools: list, detail, rename, and payee location tools.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

_list_payees async

_list_payees(
    app: AppContext, budget_id: str, *, include_transfers: bool = False
) -> str

List all payees in a budget.

Returns:

  • str

    Structured text with count header and payee lines.

Source code in src/ynaa_mcp/tools/payees.py
async def _list_payees(
    app: AppContext,
    budget_id: str,
    *,
    include_transfers: bool = False,
) -> str:
    """List all payees in a budget.

    Returns:
        Structured text with count header and payee lines.
    """
    data = await app.client.get(f"/budgets/{budget_id}/payees")
    all_payees = data["payees"]
    payees = [p for p in all_payees if not p["deleted"]]
    if not include_transfers:
        payees = [p for p in payees if p.get("transfer_account_id") is None]
    if not payees:
        return "No payees found."
    count = len(payees)
    noun = "payee" if count == 1 else "payees"
    lines = [f"{count} {noun} found:"]
    for p in payees:
        lines.extend((f"- {p['name']}", f"  ID: {p['id']}"))
    return "\n".join(lines)

_get_payee async

_get_payee(app: AppContext, budget_id: str, *, payee_id: str) -> str

Get detailed information about a specific payee.

Returns:

  • str

    Structured text with payee details.

Source code in src/ynaa_mcp/tools/payees.py
async def _get_payee(
    app: AppContext,
    budget_id: str,
    *,
    payee_id: str,
) -> str:
    """Get detailed information about a specific payee.

    Returns:
        Structured text with payee details.
    """
    data = await app.client.get(f"/budgets/{budget_id}/payees/{payee_id}")
    payee = data["payee"]
    lines = [
        f"Payee: {payee['name']}",
        f"  ID: {payee['id']}",
    ]
    if payee.get("transfer_account_id"):
        lines.append(f"  Transfer account: {payee['transfer_account_id']}")
    return "\n".join(lines)

_update_name async

_update_name(app: AppContext, budget_id: str, *, payee_id: str, name: str) -> str

Rename a payee.

Returns:

  • str

    Confirmation text with the updated payee name and ID.

Source code in src/ynaa_mcp/tools/payees.py
async def _update_name(
    app: AppContext,
    budget_id: str,
    *,
    payee_id: str,
    name: str,
) -> str:
    """Rename a payee.

    Returns:
        Confirmation text with the updated payee name and ID.
    """
    data = await app.client.patch(
        f"/budgets/{budget_id}/payees/{payee_id}",
        json={"payee": {"name": name}},
    )
    payee = data["payee"]
    return f"Payee renamed to '{payee['name']}'.\n  ID: {payee['id']}"

_list_locations async

_list_locations(app: AppContext, budget_id: str, *, payee_id: str | None = None) -> str

List payee locations, optionally filtered by payee.

Returns:

  • str

    Structured text with count header and location lines.

Source code in src/ynaa_mcp/tools/payees.py
async def _list_locations(
    app: AppContext,
    budget_id: str,
    *,
    payee_id: str | None = None,
) -> str:
    """List payee locations, optionally filtered by payee.

    Returns:
        Structured text with count header and location lines.
    """
    if payee_id:
        path = f"/budgets/{budget_id}/payees/{payee_id}/payee_locations"
    else:
        path = f"/budgets/{budget_id}/payee_locations"
    data = await app.client.get(path)
    all_locations = data["payee_locations"]
    locations = [loc for loc in all_locations if not loc["deleted"]]
    if not locations:
        return "No payee locations found."
    count = len(locations)
    noun = "payee location" if count == 1 else "payee locations"
    lines = [f"{count} {noun} found:"]
    for loc in locations:
        lines.extend((
            f"- {loc['payee_id']} | lat: {loc['latitude']}, lon: {loc['longitude']}",
            f"  ID: {loc['id']}",
        ))
    return "\n".join(lines)

_get_location async

_get_location(app: AppContext, budget_id: str, *, payee_location_id: str) -> str

Get detailed information about a specific payee location.

Returns:

  • str

    Structured text with payee location details.

Source code in src/ynaa_mcp/tools/payees.py
async def _get_location(
    app: AppContext,
    budget_id: str,
    *,
    payee_location_id: str,
) -> str:
    """Get detailed information about a specific payee location.

    Returns:
        Structured text with payee location details.
    """
    data = await app.client.get(
        f"/budgets/{budget_id}/payee_locations/{payee_location_id}",
    )
    loc = data["payee_location"]
    return (
        f"Payee location:\n"
        f"  ID: {loc['id']}\n"
        f"  Payee ID: {loc['payee_id']}\n"
        f"  Latitude: {loc['latitude']}\n"
        f"  Longitude: {loc['longitude']}"
    )

manage_payees async

manage_payees(
    ctx: Context,
    action: Literal["list", "get", "update_name", "list_locations", "get_location"],
    budget_id_or_name: str = "last-used",
    include_transfers: bool = False,
    payee_id: str | None = None,
    name: str | None = None,
    payee_location_id: str | None = None,
) -> str

Manage YNAB payees: list, get details, rename, and query locations.

Actions

list: List all payees. Uses budget_id_or_name, include_transfers. get: Get payee details. Uses payee_id (required). update_name: Rename payee. Uses payee_id (required), name (required). list_locations: List payee locations. Uses payee_id (optional filter). get_location: Get location details. Uses payee_location_id (required).

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'update_name', 'list_locations', 'get_location']) –

    The operation to perform.

  • budget_id_or_name (str, default: 'last-used' ) –

    Budget UUID or name. Defaults to "last-used".

  • include_transfers (bool, default: False ) –

    If True, include transfer payees (list only).

  • payee_id (str | None, default: None ) –

    The payee UUID (get, update_name, list_locations).

  • name (str | None, default: None ) –

    New name for the payee (update_name only).

  • payee_location_id (str | None, default: None ) –

    The payee location UUID (get_location only).

Returns:

  • str

    Structured text with payee information or confirmation.

Raises:

  • ToolError

    If required parameters for the action are missing.

Source code in src/ynaa_mcp/tools/payees.py
@mcp.tool
async def manage_payees(  # noqa: PLR0913, PLR0917
    ctx: Context,
    action: Literal["list", "get", "update_name", "list_locations", "get_location"],
    budget_id_or_name: str = "last-used",
    include_transfers: bool = False,  # noqa: FBT001, FBT002
    payee_id: str | None = None,
    name: str | None = None,
    payee_location_id: str | None = None,
) -> str:
    """Manage YNAB payees: list, get details, rename, and query locations.

    Actions:
        list: List all payees. Uses budget_id_or_name, include_transfers.
        get: Get payee details. Uses payee_id (required).
        update_name: Rename payee. Uses payee_id (required), name (required).
        list_locations: List payee locations. Uses payee_id (optional filter).
        get_location: Get location details. Uses payee_location_id (required).

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name. Defaults to "last-used".
        include_transfers: If True, include transfer payees (list only).
        payee_id: The payee UUID (get, update_name, list_locations).
        name: New name for the payee (update_name only).
        payee_location_id: The payee location UUID (get_location only).

    Returns:
        Structured text with payee information or confirmation.

    Raises:
        ToolError: If required parameters for the action are missing.
    """
    app = cast("AppContext", ctx.lifespan_context)
    budget_id, _info = await resolve_budget(
        app.client, budget_id_or_name, cache=app.cache
    )

    if action == "list":
        return await _list_payees(app, budget_id, include_transfers=include_transfers)
    if action == "get":
        if payee_id is None:
            msg = "payee_id is required for action='get'"
            raise ToolError(msg)
        return await _get_payee(app, budget_id, payee_id=payee_id)
    if action == "update_name":
        if payee_id is None or name is None:
            msg = "payee_id and name are required for action='update_name'"
            raise ToolError(msg)
        return await _update_name(app, budget_id, payee_id=payee_id, name=name)
    if action == "list_locations":
        return await _list_locations(app, budget_id, payee_id=payee_id)
    if payee_location_id is None:
        msg = "payee_location_id is required for action='get_location'"
        raise ToolError(msg)
    return await _get_location(app, budget_id, payee_location_id=payee_location_id)

Months

months

Month tools: consolidated manage_months with action-parameter dispatch.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

format_dollars

format_dollars(amount: float) -> str

Format a dollar amount with $ symbol and comma separators.

Parameters:

  • amount (float) –

    Dollar amount (already converted from milliunits).

Returns:

  • str

    Formatted string like "$1,234.56" or "-$1,234.56".

Examples:

>>> format_dollars(1234.56)
'$1,234.56'
>>> format_dollars(-50.0)
'-$50.00'
>>> format_dollars(0.0)
'$0.00'
Source code in src/ynaa_mcp/converters.py
def format_dollars(amount: float) -> str:
    """Format a dollar amount with $ symbol and comma separators.

    Args:
        amount: Dollar amount (already converted from milliunits).

    Returns:
        Formatted string like "$1,234.56" or "-$1,234.56".

    Examples:
        >>> format_dollars(1234.56)
        '$1,234.56'
        >>> format_dollars(-50.0)
        '-$50.00'
        >>> format_dollars(0.0)
        '$0.00'
    """
    if amount < 0:
        return f"-${abs(amount):,.2f}"
    return f"${amount:,.2f}"

normalize_month

normalize_month(month: str | None) -> str

Normalize a month parameter for the YNAB API.

Accepts "YYYY-MM", "YYYY-MM-DD", or None (current month).

Parameters:

  • month (str | None) –

    Month string or None for current month.

Returns:

  • str

    "current" or "YYYY-MM-01" format string.

Examples:

>>> normalize_month(None)
'current'
>>> normalize_month("2026-03")
'2026-03-01'
>>> normalize_month("2026-03-01")
'2026-03-01'
Source code in src/ynaa_mcp/converters.py
def normalize_month(month: str | None) -> str:
    """Normalize a month parameter for the YNAB API.

    Accepts ``"YYYY-MM"``, ``"YYYY-MM-DD"``, or ``None`` (current month).

    Args:
        month: Month string or None for current month.

    Returns:
        ``"current"`` or ``"YYYY-MM-01"`` format string.

    Examples:
        >>> normalize_month(None)
        'current'
        >>> normalize_month("2026-03")
        '2026-03-01'
        >>> normalize_month("2026-03-01")
        '2026-03-01'
    """
    if month is None:
        return "current"
    if len(month) == _YYYY_MM_LENGTH:
        return f"{month}-01"
    return month

_list_months async

_list_months(app: AppContext, budget_id: str) -> str

List all budget months, excluding deleted ones.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

Returns:

  • str

    Structured text with count header and month summaries,

  • str

    or "No months found." if none exist.

Source code in src/ynaa_mcp/tools/months.py
async def _list_months(app: AppContext, budget_id: str) -> str:
    """List all budget months, excluding deleted ones.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.

    Returns:
        Structured text with count header and month summaries,
        or "No months found." if none exist.
    """
    data = await app.client.get(f"/budgets/{budget_id}/months")
    all_months = data["months"]

    months = [m for m in all_months if not m["deleted"]]

    if not months:
        return "No months found."

    count = len(months)
    noun = "month" if count == 1 else "months"
    lines = [f"{count} {noun} found:"]
    for m in months:
        parts = [
            f"- {m['month']}",
            f"    Income: {format_dollars(m['income'])}",
            f"    Budgeted: {format_dollars(m['budgeted'])}",
            f"    Activity: {format_dollars(m['activity'])}",
            f"    To be budgeted: {format_dollars(m['to_be_budgeted'])}",
        ]
        if m.get("age_of_money") is not None:
            parts.append(f"    Age of money: {m['age_of_money']} days")
        lines.extend(parts)

    return "\n".join(lines)

_get_month async

_get_month(app: AppContext, budget_id: str, month: str) -> str

Get detailed information about a specific budget month.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • month (str) –

    Month as "YYYY-MM" or "YYYY-MM-DD".

Returns:

  • str

    Structured text with month summary and grouped category detail.

Source code in src/ynaa_mcp/tools/months.py
async def _get_month(app: AppContext, budget_id: str, month: str) -> str:
    """Get detailed information about a specific budget month.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        month: Month as "YYYY-MM" or "YYYY-MM-DD".

    Returns:
        Structured text with month summary and grouped category detail.
    """
    normalized = normalize_month(month)
    data = await app.client.get(f"/budgets/{budget_id}/months/{normalized}")
    m = data["month"]

    lines = [
        f"Month: {m['month']}",
        f"  Income: {format_dollars(m['income'])}",
        f"  Budgeted: {format_dollars(m['budgeted'])}",
        f"  Activity: {format_dollars(m['activity'])}",
        f"  To be budgeted: {format_dollars(m['to_be_budgeted'])}",
    ]
    if m.get("age_of_money") is not None:
        lines.append(f"  Age of money: {m['age_of_money']} days")

    # Group categories by category_group_name
    categories = m.get("categories", [])
    groups: dict[str, list[dict[str, Any]]] = {}
    for cat in categories:
        group_name = cat.get("category_group_name") or cat.get(
            "category_group_id", "Uncategorized"
        )
        groups.setdefault(group_name, []).append(cat)

    for group_name, cats in groups.items():
        lines.append(f"\n{group_name} ({len(cats)} categories):")
        for cat in cats:
            budget_line = (
                f"    Budgeted: {format_dollars(cat['budgeted'])} | "
                f"Activity: {format_dollars(cat['activity'])} | "
                f"Balance: {format_dollars(cat['balance'])}"
            )
            lines.extend((
                f"  - {cat['name']}",
                budget_line,
            ))

    return "\n".join(lines)

_list_money_movements async

_list_money_movements(app: AppContext, budget_id: str, month: str | None) -> str

List money movements in a budget, optionally scoped to a month.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • month (str | None) –

    If provided, scope to this month ("YYYY-MM" or "YYYY-MM-DD").

Returns:

  • str

    Structured text with count header and movement lines,

  • str

    or "No money movements found." if none exist.

Source code in src/ynaa_mcp/tools/months.py
async def _list_money_movements(
    app: AppContext,
    budget_id: str,
    month: str | None,
) -> str:
    """List money movements in a budget, optionally scoped to a month.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        month: If provided, scope to this month ("YYYY-MM" or "YYYY-MM-DD").

    Returns:
        Structured text with count header and movement lines,
        or "No money movements found." if none exist.
    """
    if month is not None:
        normalized = normalize_month(month)
        path = f"/budgets/{budget_id}/months/{normalized}/money_movements"
    else:
        path = f"/budgets/{budget_id}/money_movements"

    data = await app.client.get(path)
    movements = data["money_movements"]

    if not movements:
        return "No money movements found."

    count = len(movements)
    noun = "money movement" if count == 1 else "money movements"
    lines = [f"{count} {noun} found:"]
    for mv in movements:
        cat_name = mv["category_name"]
        group = mv.get("category_group_name")
        label = f"{cat_name} ({group})" if group else cat_name
        lines.extend((
            f"- {label}",
            f"    Allocation: {format_dollars(mv['allocation'])}",
            f"    Spent: {format_dollars(mv['spent'])}",
            f"    Income: {format_dollars(mv['income'])}",
        ))

    return "\n".join(lines)

_list_money_movement_groups async

_list_money_movement_groups(app: AppContext, budget_id: str, month: str | None) -> str

List money movement groups in a budget, optionally scoped to a month.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • month (str | None) –

    If provided, scope to this month ("YYYY-MM" or "YYYY-MM-DD").

Returns:

  • str

    Structured text with count header and group lines,

  • str

    or "No money movement groups found." if none exist.

Source code in src/ynaa_mcp/tools/months.py
async def _list_money_movement_groups(
    app: AppContext,
    budget_id: str,
    month: str | None,
) -> str:
    """List money movement groups in a budget, optionally scoped to a month.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        month: If provided, scope to this month ("YYYY-MM" or "YYYY-MM-DD").

    Returns:
        Structured text with count header and group lines,
        or "No money movement groups found." if none exist.
    """
    if month is not None:
        normalized = normalize_month(month)
        path = f"/budgets/{budget_id}/months/{normalized}/money_movement_groups"
    else:
        path = f"/budgets/{budget_id}/money_movement_groups"

    data = await app.client.get(path)
    groups = data["money_movement_groups"]

    if not groups:
        return "No money movement groups found."

    count = len(groups)
    noun = "money movement group" if count == 1 else "money movement groups"
    lines = [f"{count} {noun} found:"]
    for grp in groups:
        lines.extend((
            f"- {grp['category_group_name']}",
            f"    Allocation: {format_dollars(grp['allocation'])}",
            f"    Spent: {format_dollars(grp['spent'])}",
            f"    Income: {format_dollars(grp['income'])}",
        ))

    return "\n".join(lines)

manage_months async

manage_months(
    ctx: Context,
    action: Literal[
        "list", "get", "list_money_movements", "list_money_movement_groups"
    ],
    budget_id_or_name: str = "last-used",
    month: str | None = None,
) -> str

Manage YNAB budget months: list, get detail, and view money movements.

Dispatches to the appropriate action based on the action parameter.

Actions

list: List all budget months with income, budgeted, activity, to-be-budgeted, and age of money. Params: budget_id_or_name. get: Get detailed month with category breakdowns grouped by category group. Params: budget_id_or_name, month (required). list_money_movements: List money movements (category-level). Params: budget_id_or_name, month (optional -- all if omitted). list_money_movement_groups: List money movement groups (group-level). Params: budget_id_or_name, month (optional -- all if omitted).

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'list_money_movements', 'list_money_movement_groups']) –

    The operation to perform.

  • budget_id_or_name (str, default: 'last-used' ) –

    Budget UUID or name. Defaults to "last-used".

  • month (str | None, default: None ) –

    Month as "YYYY-MM" or "YYYY-MM-DD". Required for "get", optional for "list_money_movements" and "list_money_movement_groups".

Returns:

  • str

    Structured text with the requested month data.

Raises:

  • ToolError

    If "get" is called without month.

Source code in src/ynaa_mcp/tools/months.py
@mcp.tool
async def manage_months(
    ctx: Context,
    action: Literal[
        "list", "get", "list_money_movements", "list_money_movement_groups"
    ],
    budget_id_or_name: str = "last-used",
    month: str | None = None,
) -> str:
    """Manage YNAB budget months: list, get detail, and view money movements.

    Dispatches to the appropriate action based on the ``action`` parameter.

    Actions:
        list: List all budget months with income, budgeted, activity,
            to-be-budgeted, and age of money. Params: budget_id_or_name.
        get: Get detailed month with category breakdowns grouped by
            category group. Params: budget_id_or_name, month (required).
        list_money_movements: List money movements (category-level).
            Params: budget_id_or_name, month (optional -- all if omitted).
        list_money_movement_groups: List money movement groups (group-level).
            Params: budget_id_or_name, month (optional -- all if omitted).

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name. Defaults to "last-used".
        month: Month as "YYYY-MM" or "YYYY-MM-DD". Required for "get",
            optional for "list_money_movements" and
            "list_money_movement_groups".

    Returns:
        Structured text with the requested month data.

    Raises:
        ToolError: If "get" is called without ``month``.
    """
    app = cast("AppContext", ctx.lifespan_context)
    budget_id, _info = await resolve_budget(
        app.client, budget_id_or_name, cache=app.cache
    )

    if action == "list":
        return await _list_months(app, budget_id)

    if action == "get":
        if month is None:
            msg = "action='get' requires 'month' parameter"
            raise ToolError(msg)
        return await _get_month(app, budget_id, month)

    if action == "list_money_movements":
        return await _list_money_movements(app, budget_id, month)

    # Last action: list_money_movement_groups
    return await _list_money_movement_groups(app, budget_id, month)

Scheduled Transactions

scheduled

Scheduled transaction tools: consolidated manage_scheduled_transactions.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

resolve_budget async

resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]

Resolve a budget identifier to a budget ID.

Fetches the list of budgets from the YNAB API and resolves the given identifier to a concrete budget ID. Supports three modes:

  1. No identifier: Auto-selects if exactly one budget exists, otherwise lists available budgets in the error message.
  2. UUID: Returns the ID directly if it matches a known budget.
  3. Fuzzy name: Case-insensitive fuzzy matching using difflib.SequenceMatcher with a threshold of 0.6.

Parameters:

  • client (YNABClient) –

    The YNAB API client instance.

  • budget_id_or_name (str | None, default: None ) –

    Optional budget UUID or name to resolve.

  • cache (CacheStore | None, default: None ) –

    Optional CacheStore for TTL-based budget list caching.

Returns:

  • str

    A tuple of (budget_id, info_message) where info_message

  • str | None

    is None or an informational note about how the budget was resolved.

Raises:

  • ToolError

    If resolution fails (no budgets, ambiguous, no match).

Source code in src/ynaa_mcp/budget_resolver.py
async def resolve_budget(
    client: YNABClient,
    budget_id_or_name: str | None = None,
    *,
    cache: CacheStore | None = None,
) -> tuple[str, str | None]:
    """Resolve a budget identifier to a budget ID.

    Fetches the list of budgets from the YNAB API and resolves the
    given identifier to a concrete budget ID. Supports three modes:

    1. **No identifier:** Auto-selects if exactly one budget exists,
       otherwise lists available budgets in the error message.
    2. **UUID:** Returns the ID directly if it matches a known budget.
    3. **Fuzzy name:** Case-insensitive fuzzy matching using
       ``difflib.SequenceMatcher`` with a threshold of 0.6.

    Args:
        client: The YNAB API client instance.
        budget_id_or_name: Optional budget UUID or name to resolve.
        cache: Optional CacheStore for TTL-based budget list caching.

    Returns:
        A tuple of ``(budget_id, info_message)`` where ``info_message``
        is None or an informational note about how the budget was resolved.

    Raises:
        ToolError: If resolution fails (no budgets, ambiguous, no match).
    """
    cached_data = cache.get_ttl("budgets") if cache else None
    if cached_data is not None:
        data = cached_data
    else:
        data = await client.get("/budgets")
        if cache is not None:
            cache.set_ttl("budgets", data, ttl_seconds=_BUDGET_LIST_TTL)
    budgets_response = BudgetsResponse.model_validate(data)
    budgets = budgets_response.budgets

    if not budgets:
        msg = "No budgets found. Create a budget at app.ynab.com first."
        raise ToolError(msg)

    if budget_id_or_name is None:
        return _resolve_without_identifier(budgets)

    return _resolve_with_identifier(budgets, budget_id_or_name)

dollars_to_milliunits

dollars_to_milliunits(dollars: float) -> int

Convert a dollar amount to YNAB milliunits.

Uses Decimal(str(dollars)) to avoid floating-point precision issues, then rounds to the nearest milliunit using ROUND_HALF_UP.

Parameters:

  • dollars (float) –

    Dollar amount to convert.

Returns:

  • int

    The equivalent amount in YNAB milliunits as an integer.

Examples:

>>> dollars_to_milliunits(45.67)
45670
>>> dollars_to_milliunits(0.1 + 0.2)
300
Source code in src/ynaa_mcp/converters.py
def dollars_to_milliunits(dollars: float) -> int:
    """Convert a dollar amount to YNAB milliunits.

    Uses ``Decimal(str(dollars))`` to avoid floating-point precision issues,
    then rounds to the nearest milliunit using ROUND_HALF_UP.

    Args:
        dollars: Dollar amount to convert.

    Returns:
        The equivalent amount in YNAB milliunits as an integer.

    Examples:
        >>> dollars_to_milliunits(45.67)
        45670
        >>> dollars_to_milliunits(0.1 + 0.2)
        300
    """
    result = Decimal(str(dollars)) * MILLIUNIT_FACTOR
    return int(result.quantize(Decimal(1), rounding=ROUND_HALF_UP))

format_dollars

format_dollars(amount: float) -> str

Format a dollar amount with $ symbol and comma separators.

Parameters:

  • amount (float) –

    Dollar amount (already converted from milliunits).

Returns:

  • str

    Formatted string like "$1,234.56" or "-$1,234.56".

Examples:

>>> format_dollars(1234.56)
'$1,234.56'
>>> format_dollars(-50.0)
'-$50.00'
>>> format_dollars(0.0)
'$0.00'
Source code in src/ynaa_mcp/converters.py
def format_dollars(amount: float) -> str:
    """Format a dollar amount with $ symbol and comma separators.

    Args:
        amount: Dollar amount (already converted from milliunits).

    Returns:
        Formatted string like "$1,234.56" or "-$1,234.56".

    Examples:
        >>> format_dollars(1234.56)
        '$1,234.56'
        >>> format_dollars(-50.0)
        '-$50.00'
        >>> format_dollars(0.0)
        '$0.00'
    """
    if amount < 0:
        return f"-${abs(amount):,.2f}"
    return f"${amount:,.2f}"

_format_scheduled_transaction_line

_format_scheduled_transaction_line(txn: dict[str, Any]) -> list[str]

Format a single scheduled transaction for list view.

Each transaction produces two lines: a summary line with next date (or first date), payee, amount, category, and frequency, followed by the transaction ID.

Parameters:

  • txn (dict[str, Any]) –

    Scheduled transaction dict from the YNAB API response.

Returns:

  • list[str]

    Two-element list: summary line and ID line.

Source code in src/ynaa_mcp/tools/scheduled.py
def _format_scheduled_transaction_line(txn: dict[str, Any]) -> list[str]:
    """Format a single scheduled transaction for list view.

    Each transaction produces two lines: a summary line with next date
    (or first date), payee, amount, category, and frequency, followed
    by the transaction ID.

    Args:
        txn: Scheduled transaction dict from the YNAB API response.

    Returns:
        Two-element list: summary line and ID line.
    """
    display_date = txn.get("date_next") or txn.get("date_first", "")
    payee = txn.get("payee_name") or "(no payee)"
    category = txn.get("category_name") or "(no category)"
    amount = format_dollars(txn["amount"])
    frequency = txn.get("frequency", "")
    return [
        f"- {display_date} | {payee} | {amount} | {category} [{frequency}]",
        f"  ID: {txn['id']}",
    ]

_format_scheduled_transaction_detail

_format_scheduled_transaction_detail(txn: dict[str, Any]) -> list[str]

Format a single scheduled transaction for detail view.

Includes all fields with optional ones only shown when present. Subtransactions are displayed as an indented list.

Parameters:

  • txn (dict[str, Any]) –

    Scheduled transaction dict from the YNAB API response.

Returns:

  • list[str]

    List of formatted lines for the detail view.

Source code in src/ynaa_mcp/tools/scheduled.py
def _format_scheduled_transaction_detail(txn: dict[str, Any]) -> list[str]:
    """Format a single scheduled transaction for detail view.

    Includes all fields with optional ones only shown when present.
    Subtransactions are displayed as an indented list.

    Args:
        txn: Scheduled transaction dict from the YNAB API response.

    Returns:
        List of formatted lines for the detail view.
    """
    payee = txn.get("payee_name") or "(no payee)"
    lines = [
        f"Scheduled: {payee}",
        f"  ID: {txn['id']}",
        f"  Amount: {format_dollars(txn['amount'])}",
        f"  Account: {txn['account_name']}",
        f"  Category: {txn.get('category_name') or '(none)'}",
        f"  Frequency: {txn['frequency']}",
        f"  First date: {txn['date_first']}",
    ]
    if txn.get("date_next"):
        lines.append(f"  Next date: {txn['date_next']}")
    if txn.get("memo"):
        lines.append(f"  Memo: {txn['memo']}")
    if txn.get("flag_color"):
        lines.append(f"  Flag: {txn['flag_color']}")

    subtxns = txn.get("subtransactions", [])
    if subtxns:
        lines.append(f"  Split ({len(subtxns)} items):")
        for sub in subtxns:
            sub_cat = sub.get("category_name") or "(no category)"
            lines.append(f"    - {format_dollars(sub['amount'])} | {sub_cat}")
            if sub.get("memo"):
                lines.append(f"      Memo: {sub['memo']}")
    return lines

_format_scheduled_transaction_confirmation

_format_scheduled_transaction_confirmation(verb: str, txn: dict[str, Any]) -> str

Format a scheduled transaction create/update/delete confirmation.

Parameters:

  • verb (str) –

    Action word ("created", "updated", "deleted").

  • txn (dict[str, Any]) –

    Scheduled transaction dict from the YNAB API response.

Returns:

  • str

    Confirmation string with key scheduled transaction fields.

Source code in src/ynaa_mcp/tools/scheduled.py
def _format_scheduled_transaction_confirmation(verb: str, txn: dict[str, Any]) -> str:
    """Format a scheduled transaction create/update/delete confirmation.

    Args:
        verb: Action word ("created", "updated", "deleted").
        txn: Scheduled transaction dict from the YNAB API response.

    Returns:
        Confirmation string with key scheduled transaction fields.
    """
    payee = txn.get("payee_name") or "(no payee)"
    lines = [
        f"Scheduled transaction {verb}:",
        f"  ID: {txn['id']}",
        f"  Payee: {payee}",
        f"  Amount: {format_dollars(txn['amount'])}",
        f"  Account: {txn['account_name']}",
        f"  Frequency: {txn.get('frequency', 'N/A')}",
    ]
    return "\n".join(lines)

_list_scheduled async

_list_scheduled(app: AppContext, budget_id: str) -> str

List all scheduled transactions, excluding deleted ones.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

Returns:

  • str

    Structured text with count header and scheduled transaction lines,

  • str

    or "No scheduled transactions found." if none exist.

Source code in src/ynaa_mcp/tools/scheduled.py
async def _list_scheduled(app: AppContext, budget_id: str) -> str:
    """List all scheduled transactions, excluding deleted ones.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.

    Returns:
        Structured text with count header and scheduled transaction lines,
        or "No scheduled transactions found." if none exist.
    """
    data = await app.client.get(f"/budgets/{budget_id}/scheduled_transactions")
    all_txns = data["scheduled_transactions"]

    txns = [t for t in all_txns if not t["deleted"]]

    if not txns:
        return "No scheduled transactions found."

    count = len(txns)
    noun = "scheduled transaction" if count == 1 else "scheduled transactions"
    lines = [f"{count} {noun} found:"]
    for txn in txns:
        lines.extend(_format_scheduled_transaction_line(txn))

    return "\n".join(lines)

_get_scheduled async

_get_scheduled(app: AppContext, budget_id: str, scheduled_transaction_id: str) -> str

Get detailed information about a specific scheduled transaction.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • scheduled_transaction_id (str) –

    The scheduled transaction UUID.

Returns:

  • str

    Structured text with full scheduled transaction detail view.

Source code in src/ynaa_mcp/tools/scheduled.py
async def _get_scheduled(
    app: AppContext,
    budget_id: str,
    scheduled_transaction_id: str,
) -> str:
    """Get detailed information about a specific scheduled transaction.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        scheduled_transaction_id: The scheduled transaction UUID.

    Returns:
        Structured text with full scheduled transaction detail view.
    """
    data = await app.client.get(
        f"/budgets/{budget_id}/scheduled_transactions/{scheduled_transaction_id}",
    )
    txn = data["scheduled_transaction"]

    lines = _format_scheduled_transaction_detail(txn)
    return "\n".join(lines)

_create_scheduled async

_create_scheduled(
    app: AppContext,
    budget_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    frequency: str | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    flag_color: str | None,
) -> str

Create a new scheduled transaction.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • account_id (str | None) –

    Account UUID (required).

  • date (str | None) –

    Scheduled transaction date (required).

  • amount (float | None) –

    Amount in dollars (converted to milliunits).

  • frequency (str | None) –

    Recurrence frequency.

  • payee_name (str | None) –

    Payee display name.

  • payee_id (str | None) –

    Payee UUID.

  • category_id (str | None) –

    Category UUID.

  • memo (str | None) –

    Memo text.

  • flag_color (str | None) –

    Flag color.

Returns:

  • str

    Confirmation text with key scheduled transaction fields.

Raises:

  • ToolError

    If required fields (account_id, date) are missing.

Source code in src/ynaa_mcp/tools/scheduled.py
async def _create_scheduled(  # noqa: PLR0913, PLR0917, C901
    app: AppContext,
    budget_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    frequency: str | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    flag_color: str | None,
) -> str:
    """Create a new scheduled transaction.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        account_id: Account UUID (required).
        date: Scheduled transaction date (required).
        amount: Amount in dollars (converted to milliunits).
        frequency: Recurrence frequency.
        payee_name: Payee display name.
        payee_id: Payee UUID.
        category_id: Category UUID.
        memo: Memo text.
        flag_color: Flag color.

    Returns:
        Confirmation text with key scheduled transaction fields.

    Raises:
        ToolError: If required fields (account_id, date) are missing.
    """
    missing: list[str] = []
    if account_id is None:
        missing.append("account_id")
    if date is None:
        missing.append("date")
    if missing:
        msg = f"Create requires: {', '.join(missing)}"
        raise ToolError(msg)

    # Narrowing: pyright can't track list-based None checks, assert after guard
    assert account_id is not None
    assert date is not None

    optional_fields: dict[str, Any] = {}
    if payee_name is not None:
        optional_fields["payee_name"] = payee_name
    if payee_id is not None:
        optional_fields["payee_id"] = payee_id
    if category_id is not None:
        optional_fields["category_id"] = category_id
    if memo is not None:
        optional_fields["memo"] = memo
    if flag_color is not None:
        optional_fields["flag_color"] = flag_color
    if frequency is not None:
        optional_fields["frequency"] = frequency

    body: dict[str, Any] = {
        "account_id": account_id,
        "date": date,
        **optional_fields,
    }
    if amount is not None:
        body["amount"] = dollars_to_milliunits(amount)

    data = await app.client.post(
        f"/budgets/{budget_id}/scheduled_transactions",
        json={"scheduled_transaction": body},
    )
    txn = data["scheduled_transaction"]
    return _format_scheduled_transaction_confirmation("created", txn)

_update_scheduled async

_update_scheduled(
    app: AppContext,
    budget_id: str,
    scheduled_transaction_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    frequency: str | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    flag_color: str | None,
) -> str

Update an existing scheduled transaction.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • scheduled_transaction_id (str) –

    The scheduled transaction UUID.

  • account_id (str | None) –

    Account UUID.

  • date (str | None) –

    Scheduled transaction date.

  • amount (float | None) –

    Amount in dollars (converted to milliunits).

  • frequency (str | None) –

    Recurrence frequency.

  • payee_name (str | None) –

    Payee display name.

  • payee_id (str | None) –

    Payee UUID.

  • category_id (str | None) –

    Category UUID.

  • memo (str | None) –

    Memo text.

  • flag_color (str | None) –

    Flag color.

Returns:

  • str

    Confirmation text with key scheduled transaction fields.

Source code in src/ynaa_mcp/tools/scheduled.py
async def _update_scheduled(  # noqa: PLR0913, PLR0917
    app: AppContext,
    budget_id: str,
    scheduled_transaction_id: str,
    account_id: str | None,
    date: str | None,
    amount: float | None,
    frequency: str | None,
    payee_name: str | None,
    payee_id: str | None,
    category_id: str | None,
    memo: str | None,
    flag_color: str | None,
) -> str:
    """Update an existing scheduled transaction.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        scheduled_transaction_id: The scheduled transaction UUID.
        account_id: Account UUID.
        date: Scheduled transaction date.
        amount: Amount in dollars (converted to milliunits).
        frequency: Recurrence frequency.
        payee_name: Payee display name.
        payee_id: Payee UUID.
        category_id: Category UUID.
        memo: Memo text.
        flag_color: Flag color.

    Returns:
        Confirmation text with key scheduled transaction fields.
    """
    optional_fields: dict[str, Any] = {}
    if payee_name is not None:
        optional_fields["payee_name"] = payee_name
    if payee_id is not None:
        optional_fields["payee_id"] = payee_id
    if category_id is not None:
        optional_fields["category_id"] = category_id
    if memo is not None:
        optional_fields["memo"] = memo
    if flag_color is not None:
        optional_fields["flag_color"] = flag_color
    if frequency is not None:
        optional_fields["frequency"] = frequency

    body: dict[str, Any] = {**optional_fields}
    if amount is not None:
        body["amount"] = dollars_to_milliunits(amount)
    if date is not None:
        body["date"] = date
    if account_id is not None:
        body["account_id"] = account_id

    data = await app.client.put(
        f"/budgets/{budget_id}/scheduled_transactions/{scheduled_transaction_id}",
        json={"scheduled_transaction": body},
    )
    txn = data["scheduled_transaction"]
    return _format_scheduled_transaction_confirmation("updated", txn)

_delete_scheduled async

_delete_scheduled(
    app: AppContext, budget_id: str, scheduled_transaction_id: str
) -> str

Delete a scheduled transaction.

Parameters:

  • app (AppContext) –

    The application context with client.

  • budget_id (str) –

    Resolved budget UUID.

  • scheduled_transaction_id (str) –

    The scheduled transaction UUID.

Returns:

  • str

    Confirmation text with deleted scheduled transaction details.

Source code in src/ynaa_mcp/tools/scheduled.py
async def _delete_scheduled(
    app: AppContext,
    budget_id: str,
    scheduled_transaction_id: str,
) -> str:
    """Delete a scheduled transaction.

    Args:
        app: The application context with client.
        budget_id: Resolved budget UUID.
        scheduled_transaction_id: The scheduled transaction UUID.

    Returns:
        Confirmation text with deleted scheduled transaction details.
    """
    data = await app.client.delete(
        f"/budgets/{budget_id}/scheduled_transactions/{scheduled_transaction_id}",
    )
    txn = data["scheduled_transaction"]
    return _format_scheduled_transaction_confirmation("deleted", txn)

manage_scheduled_transactions async

manage_scheduled_transactions(
    ctx: Context,
    action: Literal["list", "get", "create", "update", "delete"],
    budget_id_or_name: str = "last-used",
    scheduled_transaction_id: str | None = None,
    account_id: str | None = None,
    date: str | None = None,
    amount: float | None = None,
    frequency: str | None = None,
    payee_name: str | None = None,
    payee_id: str | None = None,
    category_id: str | None = None,
    memo: str | None = None,
    flag_color: str | None = None,
) -> str

Manage YNAB scheduled transactions: list, get, create, update, delete.

Dispatches to the appropriate action based on the action parameter.

Actions

list: List all scheduled transactions (excludes deleted). Params: budget_id_or_name. get: Get full detail for a scheduled transaction. Params: budget_id_or_name, scheduled_transaction_id (required). create: Create a new scheduled transaction. Params: budget_id_or_name, account_id (required), date (required), amount, frequency, payee_name, payee_id, category_id, memo, flag_color. update: Update an existing scheduled transaction. Params: budget_id_or_name, scheduled_transaction_id (required), plus any optional fields to change. delete: Delete a scheduled transaction. Params: budget_id_or_name, scheduled_transaction_id (required).

Parameters:

  • ctx (Context) –

    The MCP context providing access to lifespan dependencies.

  • action (Literal['list', 'get', 'create', 'update', 'delete']) –

    The operation to perform.

  • budget_id_or_name (str, default: 'last-used' ) –

    Budget UUID or name. Defaults to "last-used".

  • scheduled_transaction_id (str | None, default: None ) –

    Scheduled transaction UUID (required for get, update, delete).

  • account_id (str | None, default: None ) –

    Account UUID (required for create).

  • date (str | None, default: None ) –

    Scheduled transaction date as ISO string (required for create).

  • amount (float | None, default: None ) –

    Amount in dollars (converted to YNAB milliunits).

  • frequency (str | None, default: None ) –

    Recurrence frequency (never, daily, weekly, everyOtherWeek, twiceAMonth, every4Weeks, monthly, everyOtherMonth, every3Months, every4Months, twiceAYear, yearly, everyOtherYear).

  • payee_name (str | None, default: None ) –

    Payee display name.

  • payee_id (str | None, default: None ) –

    Payee UUID.

  • category_id (str | None, default: None ) –

    Category UUID.

  • memo (str | None, default: None ) –

    Scheduled transaction memo.

  • flag_color (str | None, default: None ) –

    Flag color for the scheduled transaction.

Returns:

  • str

    Structured text with the requested scheduled transaction data.

Raises:

  • ToolError

    If required parameters for the action are missing.

Source code in src/ynaa_mcp/tools/scheduled.py
@mcp.tool
async def manage_scheduled_transactions(  # noqa: PLR0913, PLR0917
    ctx: Context,
    action: Literal["list", "get", "create", "update", "delete"],
    budget_id_or_name: str = "last-used",
    scheduled_transaction_id: str | None = None,
    account_id: str | None = None,
    date: str | None = None,
    amount: float | None = None,
    frequency: str | None = None,
    payee_name: str | None = None,
    payee_id: str | None = None,
    category_id: str | None = None,
    memo: str | None = None,
    flag_color: str | None = None,
) -> str:
    """Manage YNAB scheduled transactions: list, get, create, update, delete.

    Dispatches to the appropriate action based on the ``action`` parameter.

    Actions:
        list: List all scheduled transactions (excludes deleted).
            Params: budget_id_or_name.
        get: Get full detail for a scheduled transaction.
            Params: budget_id_or_name, scheduled_transaction_id (required).
        create: Create a new scheduled transaction.
            Params: budget_id_or_name, account_id (required), date (required),
            amount, frequency, payee_name, payee_id, category_id, memo,
            flag_color.
        update: Update an existing scheduled transaction.
            Params: budget_id_or_name, scheduled_transaction_id (required),
            plus any optional fields to change.
        delete: Delete a scheduled transaction.
            Params: budget_id_or_name, scheduled_transaction_id (required).

    Args:
        ctx: The MCP context providing access to lifespan dependencies.
        action: The operation to perform.
        budget_id_or_name: Budget UUID or name. Defaults to "last-used".
        scheduled_transaction_id: Scheduled transaction UUID (required for
            get, update, delete).
        account_id: Account UUID (required for create).
        date: Scheduled transaction date as ISO string (required for create).
        amount: Amount in dollars (converted to YNAB milliunits).
        frequency: Recurrence frequency (never, daily, weekly, everyOtherWeek,
            twiceAMonth, every4Weeks, monthly, everyOtherMonth,
            every3Months, every4Months, twiceAYear, yearly,
            everyOtherYear).
        payee_name: Payee display name.
        payee_id: Payee UUID.
        category_id: Category UUID.
        memo: Scheduled transaction memo.
        flag_color: Flag color for the scheduled transaction.

    Returns:
        Structured text with the requested scheduled transaction data.

    Raises:
        ToolError: If required parameters for the action are missing.
    """
    app = cast("AppContext", ctx.lifespan_context)
    budget_id, _info = await resolve_budget(
        app.client, budget_id_or_name, cache=app.cache
    )

    if action == "list":
        return await _list_scheduled(app, budget_id)

    if action == "get":
        if scheduled_transaction_id is None:
            msg = "action='get' requires 'scheduled_transaction_id'"
            raise ToolError(msg)
        return await _get_scheduled(app, budget_id, scheduled_transaction_id)

    if action == "create":
        return await _create_scheduled(
            app,
            budget_id,
            account_id,
            date,
            amount,
            frequency,
            payee_name,
            payee_id,
            category_id,
            memo,
            flag_color,
        )

    if action == "update":
        if scheduled_transaction_id is None:
            msg = "action='update' requires 'scheduled_transaction_id'"
            raise ToolError(msg)
        return await _update_scheduled(
            app,
            budget_id,
            scheduled_transaction_id,
            account_id,
            date,
            amount,
            frequency,
            payee_name,
            payee_id,
            category_id,
            memo,
            flag_color,
        )

    # Last action: delete
    if scheduled_transaction_id is None:
        msg = "action='delete' requires 'scheduled_transaction_id'"
        raise ToolError(msg)
    return await _delete_scheduled(app, budget_id, scheduled_transaction_id)

Cache

cache

Cache management tool: clear cached YNAB data.

mcp module-attribute

mcp = FastMCP('YNAB', lifespan=lifespan)

AppContext dataclass

AppContext(client: YNABClient, cache: CacheStore)

Shared dependencies for all MCP tools.

Created during server lifespan and available to tools via ctx.lifespan_context.

Attributes:

  • client (YNABClient) –

    The YNAB API client instance.

  • cache (CacheStore) –

    The delta cache store for YNAB API responses.

clear_cache

clear_cache(ctx: Context, budget_id: str | None = None) -> str

Clear cached YNAB data to force fresh API requests.

Use this if you've made changes in the YNAB app or web interface and want to ensure the latest data is fetched.

Parameters:

  • ctx (Context) –

    MCP context.

  • budget_id (str | None, default: None ) –

    Optional budget ID to clear cache for. If not provided, clears all caches.

Returns:

  • str

    Confirmation message.

Source code in src/ynaa_mcp/tools/cache.py
@mcp.tool
def clear_cache(
    ctx: Context,
    budget_id: str | None = None,
) -> str:
    """Clear cached YNAB data to force fresh API requests.

    Use this if you've made changes in the YNAB app or web interface
    and want to ensure the latest data is fetched.

    Args:
        ctx: MCP context.
        budget_id: Optional budget ID to clear cache for.
            If not provided, clears all caches.

    Returns:
        Confirmation message.
    """
    app = cast("AppContext", ctx.lifespan_context)
    if budget_id:
        app.cache.invalidate_budget(budget_id)
        return f"Cache cleared for budget {budget_id}."
    app.cache.clear()
    return "All caches cleared."