Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ ynab accounts transactions <id>
```bash
ynab categories list
ynab categories view <id>
ynab categories update <id> [--name <name>] [--note <note>] [--category-group-id <id>] [--goal-target <amount>]
ynab categories budget <id> --month <YYYY-MM> --amount <amount>
ynab categories transactions <id>
```
Expand Down
58 changes: 58 additions & 0 deletions src/commands/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,64 @@ export function createCategoriesCommand(): Command {
})
);

cmd
.command('update')
.description('Update category details')
.argument('<id>', 'Category ID')
.option('--name <name>', 'New category name')
.option('--note <note>', 'Category note (use empty string to clear)')
.option('--category-group-id <id>', 'Move to a different category group')
.option('--goal-target <amount>', 'Goal target amount in dollars (ignored if category has no goal)', parseFloat)
.option('-b, --budget <id>', 'Budget ID')
.action(
withErrorHandling(
async (
id: string,
options: {
name?: string;
note?: string;
categoryGroupId?: string;
goalTarget?: number;
budget?: string;
} & CommandOptions
) => {
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
);
}

if (options.name !== undefined && options.name.trim() === '') {
throw new YnabCliError('Category name cannot be empty or whitespace', 400);
}

const updateData: {
name?: string;
note?: string | null;
category_group_id?: string;
goal_target?: number | null;
} = {};

if (options.name !== undefined) {
updateData.name = options.name.trim();
}
if (options.note !== undefined) {
updateData.note = options.note.trim() || null;
}
if (options.categoryGroupId !== undefined) {
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)')
Expand Down
9 changes: 9 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 23 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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<string, unknown> = {};
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',
Expand Down