pocketsmith-mcp
Version:
MCP server for managing budgets via PocketSmith API
158 lines (157 loc) • 7.55 kB
JavaScript
/**
* @fileoverview Update an existing category in PocketSmith
*/
import { z } from "zod";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
import { PocketSmithService } from "../../../services/pocketsmith.js";
import { NULLABLE_FIELDS, REQUIRED_NULLABLE_FIELDS } from "./schemaHelpers.js";
export const UpdateCategoryInputSchema = z.object({
apiKey: z.string().optional().describe("PocketSmith API key (if not set via environment)"),
accessToken: z.string().optional().describe("OAuth access token (if not using API key)"),
categoryId: z.number().int().describe("ID of the category to update"),
title: z.string().optional().describe("New name for the category"),
colour: NULLABLE_FIELDS.colour.describe("New hex color code for the category (e.g., #FF5733)"),
parentId: NULLABLE_FIELDS.parent_id.describe("New parent category ID (use null to remove parent)"),
isBill: z.boolean().optional().describe("Whether this category represents bills/recurring expenses"),
isTransfer: z.boolean().optional().describe("Whether this category represents transfers between accounts"),
});
export const UpdateCategoryResponseSchema = z.object({
category: z.object({
id: z.number(),
title: z.string(),
colour: REQUIRED_NULLABLE_FIELDS.colour,
is_bill: z.boolean(),
is_transfer: z.boolean(),
parent_id: REQUIRED_NULLABLE_FIELDS.parent_id,
updated_at: NULLABLE_FIELDS.updated_at,
}).describe("The updated category"),
changes: z.array(z.string()).describe("List of fields that were changed"),
success: z.boolean().describe("Whether the category was updated successfully"),
});
export async function updateCategoryLogic(params, context) {
logger.debug("Processing update category request", {
...context,
toolInput: { ...params, apiKey: "[REDACTED]", accessToken: "[REDACTED]" },
});
const apiKey = params.apiKey || process.env.POCKETSMITH_API_KEY;
const accessToken = params.accessToken || process.env.POCKETSMITH_ACCESS_TOKEN;
const service = new PocketSmithService(apiKey, accessToken);
// Get current user to fetch existing categories for comparison
const user = await service.getCurrentUser(context);
const categories = await service.getCategories(user.id, context);
// Find the original category
const originalCategory = categories.find(cat => cat.id === params.categoryId);
if (!originalCategory) {
throw new McpError(BaseErrorCode.NOT_FOUND, `Category with ID ${params.categoryId} not found`);
}
// Build the updates object and track changes
const updates = {};
const changes = [];
if (params.title !== undefined && params.title !== originalCategory.title) {
updates.title = params.title;
changes.push(`title: "${originalCategory.title}" → "${params.title}"`);
}
if (params.colour !== undefined && params.colour !== originalCategory.colour) {
updates.colour = params.colour;
const oldColour = originalCategory.colour || "(none)";
changes.push(`colour: "${oldColour}" → "${params.colour}"`);
}
if (params.parentId !== undefined && params.parentId !== originalCategory.parent_id) {
updates.parent_id = params.parentId;
const oldParent = originalCategory.parent_id || "(none)";
changes.push(`parent: "${oldParent}" → "${params.parentId}"`);
}
if (params.isBill !== undefined && params.isBill !== originalCategory.is_bill) {
updates.is_bill = params.isBill;
changes.push(`is_bill: ${originalCategory.is_bill} → ${params.isBill}`);
}
if (params.isTransfer !== undefined && params.isTransfer !== originalCategory.is_transfer) {
updates.is_transfer = params.isTransfer;
changes.push(`is_transfer: ${originalCategory.is_transfer} → ${params.isTransfer}`);
}
// Only make the API call if there are actual changes
if (Object.keys(updates).length === 0) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No changes detected. Please specify at least one field to update.");
}
// Update the category
const updatedCategory = await service.updateCategory(params.categoryId, updates, context);
const response = {
category: {
id: updatedCategory.id ?? 0,
title: updatedCategory.title ?? '',
colour: updatedCategory.colour ?? null,
is_bill: updatedCategory.is_bill ?? false,
is_transfer: updatedCategory.is_transfer ?? false,
parent_id: updatedCategory.parent_id ?? null,
updated_at: updatedCategory.updated_at ?? null,
},
changes,
success: true,
};
logger.debug("Update category processed successfully", {
...context,
categoryId: params.categoryId,
changesCount: changes.length,
});
return response;
}
export const registerUpdateCategoryTool = async (server) => {
const toolName = "update_category";
const toolDescription = "Update an existing category in PocketSmith (change name, color, parent, etc.)";
const registrationContext = requestContextService.createRequestContext({
operation: "RegisterTool",
toolName: toolName,
});
logger.info(`Registering tool: '${toolName}'`, registrationContext);
await ErrorHandler.tryCatch(async () => {
server.registerTool(toolName, {
title: "Update PocketSmith Category",
description: toolDescription,
inputSchema: UpdateCategoryInputSchema.shape,
outputSchema: UpdateCategoryResponseSchema.shape,
annotations: {
readOnlyHint: false,
openWorldHint: false,
},
}, async (params) => {
const handlerContext = requestContextService.createRequestContext({
parentRequestId: registrationContext.requestId,
operation: "HandleToolRequest",
toolName: toolName,
input: { ...params, apiKey: "[REDACTED]", accessToken: "[REDACTED]" },
});
try {
const result = await updateCategoryLogic(params, handlerContext);
return {
structuredContent: result,
content: [
{ type: "text", text: JSON.stringify(result, null, 2) },
],
};
}
catch (error) {
const mcpError = ErrorHandler.handleError(error, {
operation: "updateCategoryHandler",
context: handlerContext,
input: { ...params, apiKey: "[REDACTED]", accessToken: "[REDACTED]" },
});
return {
isError: true,
content: [{ type: "text", text: `Error: ${mcpError.message}` }],
structuredContent: {
code: mcpError.code,
message: mcpError.message,
details: mcpError.details,
},
};
}
});
logger.info(`Tool '${toolName}' registered successfully.`, registrationContext);
}, {
operation: `RegisteringTool_${toolName}`,
context: registrationContext,
errorCode: BaseErrorCode.INITIALIZATION_FAILED,
critical: true,
});
};