Skip to main content
Glama
justmytwospence

ynab-mcp

Merge Category

merge_category
Destructive

Merge two categories in YNAB by transferring all transactions and historical budgeted amounts from a source category to a target category. Preview changes with dry run before executing the merge.

Instructions

[Variable API calls] [Workflow] Merges a source category into a target category: re-categorizes all transactions and moves all historical budgeted amounts. Dry run costs 4 + N calls (N = number of budget months). Execution costs additional 1 + 2*M calls (M = months with non-zero budgets). Defaults to dry_run=true to preview changes before executing. After merging, the source category will have zero transactions and zero budgeted amounts across all months - you can then manually hide/delete it in the YNAB app.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
budget_idNoBudget ID or 'last-used'last-used
source_category_idYesCategory ID to merge FROM (will be emptied)
target_category_idYesCategory ID to merge INTO (will receive transactions and budgeted amounts)
dry_runNoPreview changes without executing (default: true)

Implementation Reference

  • The tool handler for merge_category, which performs validation, dry-run simulation, and the actual execution of merging categories and reallocating budgeted amounts.
    }, async ({ budget_id, source_category_id, target_category_id, dry_run }) => {
      try {
        let apiCalls = 0;
    
        // Step 1: Validate both categories exist
        const [sourceRes, targetRes] = await Promise.all([
          getClient().categories.getCategoryById(budget_id, source_category_id),
          getClient().categories.getCategoryById(budget_id, target_category_id),
        ]);
        apiCalls += 2;
    
        const sourceCat = sourceRes.data.category;
        const targetCat = targetRes.data.category;
    
        // Step 2: Get all transactions for source category
        const txnRes = await getClient().transactions.getTransactionsByCategory(
          budget_id, source_category_id
        );
        apiCalls += 1;
    
        const transactions = txnRes.data.transactions;
    
        // Step 3: Get all months and find which ones have non-zero source budget
        const monthsRes = await getClient().months.getPlanMonths(budget_id);
        apiCalls += 1;
    
        const allMonths = monthsRes.data.months;
    
        // For each month, we need to check the source category's budgeted amount.
        // get_month returns all categories in one call, which is more efficient
        // than calling get_month_category for each month individually.
        const monthsToAdjust: Array<{
          month: string;
          sourceBudgeted: number;
          targetBudgeted: number;
        }> = [];
    
        for (const monthSummary of allMonths) {
          const monthDetail = await getClient().months.getPlanMonth(budget_id, monthSummary.month);
          apiCalls += 1;
    
          const categories = monthDetail.data.month.categories ?? [];
          const sourceMonthCat = categories.find((c) => c.id === source_category_id);
          const targetMonthCat = categories.find((c) => c.id === target_category_id);
    
          if (sourceMonthCat && sourceMonthCat.budgeted !== 0) {
            monthsToAdjust.push({
              month: monthSummary.month,
              sourceBudgeted: sourceMonthCat.budgeted,
              targetBudgeted: targetMonthCat?.budgeted ?? 0,
            });
          }
        }
    
        // Calculate estimated total API calls for the full operation
        const updateCalls = dry_run ? 0 : (
          (transactions.length > 0 ? 1 : 0) + // bulk update transactions
          monthsToAdjust.length * 2 // update target + zero source per month
        );
        const totalEstimatedCalls = apiCalls + updateCalls;
    
        if (dry_run) {
          const lines = [
            `[DRY RUN] Merge "${sourceCat.name}" -> "${targetCat.name}"`,
            ``,
            `Transactions to re-categorize: ${transactions.length}`,
            `Monthly budgets to adjust: ${monthsToAdjust.length} months`,
            ``,
          ];
    
          if (monthsToAdjust.length > 0) {
            lines.push(`Budget adjustments:`);
            for (const m of monthsToAdjust) {
              lines.push(
                `  ${m.month}: ${formatCurrency(m.sourceBudgeted)} from "${sourceCat.name}" -> "${targetCat.name}" (currently ${formatCurrency(m.targetBudgeted)}, would become ${formatCurrency(m.targetBudgeted + m.sourceBudgeted)})`
              );
            }
            lines.push(``);
          }
    
          lines.push(`API calls used so far: ${apiCalls}`);
          lines.push(`Additional calls needed to execute: ${transactions.length > 0 ? 1 : 0} (transactions) + ${monthsToAdjust.length * 2} (budget updates) = ${updateCalls}`);
          lines.push(`Total estimated: ${totalEstimatedCalls}`);
          lines.push(``);
          lines.push(`Set dry_run=false to execute.`);
    
          return textResult(lines.join("\n"));
        }
    
        // Execute: re-categorize transactions
        let transactionsMoved = 0;
        if (transactions.length > 0) {
          await getClient().transactions.updateTransactions(budget_id, {
            transactions: transactions.map((t) => ({
              id: t.id,
              category_id: target_category_id,
            })),
          });
          apiCalls += 1;
          transactionsMoved = transactions.length;
        }
    
        // Execute: move budgeted amounts
        let monthsAdjusted = 0;
        for (const m of monthsToAdjust) {
          const newTargetBudgeted = m.targetBudgeted + m.sourceBudgeted;
    
          await getClient().categories.updateMonthCategory(
            budget_id, m.month, target_category_id,
            { category: { budgeted: newTargetBudgeted } }
          );
          apiCalls += 1;
    
          await getClient().categories.updateMonthCategory(
            budget_id, m.month, source_category_id,
            { category: { budgeted: 0 } }
          );
          apiCalls += 1;
    
          monthsAdjusted += 1;
        }
    
        const lines = [
          `Merged "${sourceCat.name}" -> "${targetCat.name}"`,
          ``,
          `Transactions re-categorized: ${transactionsMoved}`,
          `Monthly budgets adjusted: ${monthsAdjusted}`,
          `Total API calls used: ${apiCalls}`,
          ``,
          `"${sourceCat.name}" now has zero transactions and zero budgeted amounts.`,
          `You can hide or delete it manually in the YNAB app.`,
        ];
    
        return textResult(lines.join("\n"));
      } catch (e: any) {
        return errorResult(e.message);
      }
    });
  • Registration and input schema definition for the merge_category tool.
    server.registerTool("merge_category", {
      title: "Merge Category",
      description:
        "[Variable API calls] [Workflow] Merges a source category into a target category: re-categorizes all transactions and moves all historical budgeted amounts. " +
        "Dry run costs 4 + N calls (N = number of budget months). Execution costs additional 1 + 2*M calls (M = months with non-zero budgets). " +
        "Defaults to dry_run=true to preview changes before executing. " +
        "After merging, the source category will have zero transactions and zero budgeted amounts across all months - you can then manually hide/delete it in the YNAB app.",
      inputSchema: {
        budget_id: z.string().default("last-used").describe("Budget ID or 'last-used'"),
        source_category_id: z.string().describe("Category ID to merge FROM (will be emptied)"),
        target_category_id: z.string().describe("Category ID to merge INTO (will receive transactions and budgeted amounts)"),
        dry_run: z.boolean().default(true).describe("Preview changes without executing (default: true)"),
      },
      annotations: { readOnlyHint: false, destructiveHint: true },
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations indicate destructiveHint=true and readOnlyHint=false, which the description aligns with by describing the merge's effects. It adds valuable context beyond annotations: API call costs ('Dry run costs 4 + N calls...'), the outcome ('source category will have zero transactions and zero budgeted amounts'), and the need for manual cleanup. No contradiction with annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is front-loaded with the core action and efficiently covers key points in three sentences. It avoids redundancy, though the initial bracketed terms '[Variable API calls] [Workflow]' are slightly cryptic and could be more integrated.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the destructive nature (annotations cover this) and no output schema, the description provides good context: it explains the merge process, costs, default behavior, and post-merge state. It could briefly mention error cases or permissions, but overall it's sufficiently complete for a complex operation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, providing clear parameter details. The description adds minimal semantics beyond the schema, such as noting 'source_category_id' will be emptied and 'dry_run' defaults to true for previewing. This meets the baseline for high schema coverage without significant extra value.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the specific action ('merges a source category into a target category') and details what this entails ('re-categorizes all transactions and moves all historical budgeted amounts'). It distinguishes from siblings like 'update_category' by focusing on merging rather than modifying individual categories.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit guidance is provided: 'Defaults to dry_run=true to preview changes before executing' and 'After merging... you can then manually hide/delete it in the YNAB app.' It implicitly contrasts with 'delete_category' by noting manual cleanup is needed post-merge, though it doesn't name alternatives directly.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/justmytwospence/ynab-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server