From 383cabffee2e4399e54269fe48ff962595d646cc Mon Sep 17 00:00:00 2001 From: Juan Carlos Orrego Date: Wed, 28 Jan 2026 19:50:40 +0100 Subject: [PATCH 1/3] feat: add category update command - Add updateCategory method to API client - Add 'categories update' command with options for name, note, category group, and goal target - Update README with new command documentation - Support updating category name, note, moving to different group, and modifying goal target - Amounts are specified in dollars and converted to milliunits automatically --- README.md | 1 + src/commands/categories.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/lib/api-client.ts | 9 ++++++ 3 files changed, 70 insertions(+) diff --git a/README.md b/README.md index b8f0bad..481fd4a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ ynab accounts transactions ```bash ynab categories list ynab categories view +ynab categories update --name --note --category-group-id --goal-target ynab categories budget --month --amount ynab categories transactions ``` diff --git a/src/commands/categories.ts b/src/commands/categories.ts index f8ba596..7383bf2 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -36,6 +36,66 @@ export function createCategoriesCommand(): Command { }) ); + cmd + .command('update') + .description('Update category details') + .argument('', 'Category ID') + .option('--name ', 'New category name') + .option('--note ', 'New category note') + .option('--category-group-id ', 'Move to a different category group') + .option('--goal-target ', 'Goal target amount (only if goal already exists)', parseFloat) + .option('-b, --budget ', 'Budget ID') + .action( + withErrorHandling( + async ( + id: string, + options: { + name?: string; + note?: string; + categoryGroupId?: string; + goalTarget?: number; + budget?: string; + } & CommandOptions + ) => { + // Validate at least one field is provided + if (!options.name && !options.note && !options.categoryGroupId && options.goalTarget === undefined) { + throw new YnabCliError( + 'At least one field to update must be provided (--name, --note, --category-group-id, or --goal-target)', + 400 + ); + } + + // Validate goal-target if provided + if (options.goalTarget !== undefined && isNaN(options.goalTarget)) { + throw new YnabCliError('Goal target must be a valid number', 400); + } + + const updateData: { + name?: string | null; + note?: string | null; + category_group_id?: string; + goal_target?: number | null; + } = {}; + + if (options.name !== undefined) { + updateData.name = options.name.trim() || null; + } + if (options.note !== undefined) { + updateData.note = options.note.trim() || null; + } + if (options.categoryGroupId) { + updateData.category_group_id = options.categoryGroupId; + } + if (options.goalTarget !== undefined) { + updateData.goal_target = amountToMilliunits(options.goalTarget); + } + + const category = await client.updateCategory(id, { category: updateData }, options.budget); + outputJson(category); + } + ) + ); + cmd .command('budget') .description('Set category budgeted amount for a month (overrides existing amount)') diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e95ff12..f6bf6f8 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -159,6 +159,15 @@ export class YnabClient { }); } + async updateCategory(categoryId: string, data: ynab.PatchCategoryWrapper, budgetId?: string) { + return this.withErrorHandling(async () => { + const api = await this.getApi(); + const id = await this.getBudgetId(budgetId); + const response = await api.categories.updateCategory(id, categoryId, data); + return response.data.category; + }); + } + async getPayees(budgetId?: string, lastKnowledgeOfServer?: number) { return this.withErrorHandling(async () => { const api = await this.getApi(); From a578d133c121a5db27a27851d11b60b0d488d4c7 Mon Sep 17 00:00:00 2001 From: Juan Carlos Orrego Date: Wed, 28 Jan 2026 20:42:33 +0100 Subject: [PATCH 2/3] Address Copilot review comments - Fix validation to use !== undefined checks instead of falsy checks - Reject empty/whitespace category names instead of converting to null - Update README to show optional flags with brackets --- README.md | 2 +- src/commands/categories.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 481fd4a..c703552 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ ynab accounts transactions ```bash ynab categories list ynab categories view -ynab categories update --name --note --category-group-id --goal-target +ynab categories update [--name ] [--note ] [--category-group-id ] [--goal-target ] ynab categories budget --month --amount ynab categories transactions ``` diff --git a/src/commands/categories.ts b/src/commands/categories.ts index 7383bf2..feea070 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -58,27 +58,32 @@ export function createCategoriesCommand(): Command { } & CommandOptions ) => { // Validate at least one field is provided - if (!options.name && !options.note && !options.categoryGroupId && options.goalTarget === undefined) { + if (options.name === undefined && options.note === undefined && options.categoryGroupId === undefined && options.goalTarget === undefined) { throw new YnabCliError( 'At least one field to update must be provided (--name, --note, --category-group-id, or --goal-target)', 400 ); } + // Validate name if provided + if (options.name !== undefined && options.name.trim() === '') { + throw new YnabCliError('Category name cannot be empty or whitespace', 400); + } + // Validate goal-target if provided if (options.goalTarget !== undefined && isNaN(options.goalTarget)) { throw new YnabCliError('Goal target must be a valid number', 400); } const updateData: { - name?: string | null; + name?: string; note?: string | null; category_group_id?: string; goal_target?: number | null; } = {}; if (options.name !== undefined) { - updateData.name = options.name.trim() || null; + updateData.name = options.name.trim(); } if (options.note !== undefined) { updateData.note = options.note.trim() || null; From a04c302bfc3d9a79dc7c12d2edab84a1fe435824 Mon Sep 17 00:00:00 2001 From: Stephen Dolan Date: Thu, 29 Jan 2026 17:36:11 -0500 Subject: [PATCH 3/3] fix: address review findings for category update Add missing update_category MCP tool, fix categoryGroupId guard to use strict undefined check, improve option descriptions, and remove redundant comments and validation. Co-Authored-By: Claude Opus 4.5 --- src/commands/categories.ts | 13 +++---------- src/mcp/server.ts | 24 +++++++++++++++++++++++- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/commands/categories.ts b/src/commands/categories.ts index feea070..cdc9c72 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -41,9 +41,9 @@ export function createCategoriesCommand(): Command { .description('Update category details') .argument('', 'Category ID') .option('--name ', 'New category name') - .option('--note ', 'New category note') + .option('--note ', 'Category note (use empty string to clear)') .option('--category-group-id ', 'Move to a different category group') - .option('--goal-target ', 'Goal target amount (only if goal already exists)', parseFloat) + .option('--goal-target ', 'Goal target amount in dollars (ignored if category has no goal)', parseFloat) .option('-b, --budget ', 'Budget ID') .action( withErrorHandling( @@ -57,7 +57,6 @@ export function createCategoriesCommand(): Command { budget?: string; } & CommandOptions ) => { - // Validate at least one field is provided if (options.name === undefined && options.note === undefined && options.categoryGroupId === undefined && options.goalTarget === undefined) { throw new YnabCliError( 'At least one field to update must be provided (--name, --note, --category-group-id, or --goal-target)', @@ -65,16 +64,10 @@ export function createCategoriesCommand(): Command { ); } - // Validate name if provided if (options.name !== undefined && options.name.trim() === '') { throw new YnabCliError('Category name cannot be empty or whitespace', 400); } - // Validate goal-target if provided - if (options.goalTarget !== undefined && isNaN(options.goalTarget)) { - throw new YnabCliError('Goal target must be a valid number', 400); - } - const updateData: { name?: string; note?: string | null; @@ -88,7 +81,7 @@ export function createCategoriesCommand(): Command { if (options.note !== undefined) { updateData.note = options.note.trim() || null; } - if (options.categoryGroupId) { + if (options.categoryGroupId !== undefined) { updateData.category_group_id = options.categoryGroupId; } if (options.goalTarget !== undefined) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 451745c..5616c0f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { client } from '../lib/api-client.js'; import { auth } from '../lib/auth.js'; -import { convertMilliunitsToAmounts } from '../lib/utils.js'; +import { amountToMilliunits, convertMilliunitsToAmounts } from '../lib/utils.js'; const toolRegistry = [ { name: 'list_budgets', description: 'List all budgets in the YNAB account' }, @@ -12,6 +12,7 @@ const toolRegistry = [ { name: 'get_account', description: 'Get detailed information about a specific account' }, { name: 'list_categories', description: 'List all category groups and categories in a budget' }, { name: 'get_category', description: 'Get detailed information about a specific category' }, + { name: 'update_category', description: 'Update category name, note, group, or goal target' }, { name: 'list_transactions', description: 'List transactions with optional filtering' }, { name: 'get_transaction', description: 'Get detailed information about a specific transaction' }, { name: 'list_transactions_by_account', description: 'List transactions for a specific account' }, @@ -84,6 +85,27 @@ server.tool( async ({ categoryId, budgetId }) => currencyResponse(await client.getCategory(categoryId, budgetId)) ); +server.tool( + 'update_category', + 'Update category name, note, group, or goal target', + { + categoryId: z.string().describe('Category ID'), + name: z.string().optional().describe('New category name'), + note: z.string().optional().describe('Category note (use empty string to clear)'), + categoryGroupId: z.string().optional().describe('Move to a different category group'), + goalTarget: z.number().optional().describe('Goal target amount in dollars (ignored if category has no goal)'), + budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), + }, + async ({ categoryId, name, note, categoryGroupId, goalTarget, budgetId }) => { + const updateData: Record = {}; + if (name !== undefined) updateData.name = name.trim(); + if (note !== undefined) updateData.note = note.trim() || null; + if (categoryGroupId !== undefined) updateData.category_group_id = categoryGroupId; + if (goalTarget !== undefined) updateData.goal_target = amountToMilliunits(goalTarget); + return currencyResponse(await client.updateCategory(categoryId, { category: updateData }, budgetId)); + } +); + server.tool( 'list_transactions', 'List transactions with optional filtering',