From b70f0b30650215b5b17e30244a54b106695066e4 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 6 Jun 2026 03:42:28 +0000 Subject: [PATCH] feat: add write-capable MCP tools --- README.md | 14 +- SECURITY.md | 3 +- apps/api/src/mcp/endpoint.ts | 15 +- apps/api/src/mcp/server.ts | 8 +- apps/api/src/mcp/tools.test.ts | 405 +++++++++- apps/api/src/mcp/tools.ts | 1015 ++++++++++++++++++++++++-- apps/web/components/landing-page.tsx | 4 +- 7 files changed, 1364 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 4bee8f3..cbb5552 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ workflows, and MCP access from one cohesive application. on mobile and desktop. - Receive optional weekly and monthly email summaries with OpenAI-generated spending and income insights. -- Connect external tools through API keys, a typed Node client, a read-only MCP +- Connect external tools through API keys, a typed Node client, an MCP server, and a Telegram bot. ## Built With Cleverbrush @@ -250,9 +250,11 @@ subtract it from that category. ## MCP Server -xpenser exposes a read-only MCP Streamable HTTP endpoint for AI agents at +xpenser exposes an MCP Streamable HTTP endpoint for AI agents at `/external-api/mcp`. Use the same API key from Settings -> Preferences -> API -keys as a bearer token: +keys as a bearer token. MCP tools can read and manage the API-key owner's +vendors, categories, and transactions, so treat MCP access as full account data +access: ```json { @@ -268,9 +270,9 @@ keys as a bearer token: } ``` -The MCP server exposes read-only tools for the current user, categories, -transactions, dashboard summaries, and statistics. Transaction write operations -are not exposed through MCP. +The MCP server exposes tools for the current user, vendors, categories, +transactions, dashboard summaries, and statistics. Vendor candidate search and +enrichment may call the configured vendor enrichment provider. ## Development Commands diff --git a/SECURITY.md b/SECURITY.md index 7ab805a..767c492 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,5 +31,6 @@ fixes before public disclosure. - Passwords use scrypt with per-password salts. - API keys, Telegram link tokens, and email confirmation tokens are stored as hashes. -- MCP access requires an API-key principal and exposes read-only tools. +- MCP access requires an API-key principal and can read or mutate the API-key + owner's vendors, categories, and transactions. - Database spans redact SQL text at the instrumentation boundary. diff --git a/apps/api/src/mcp/endpoint.ts b/apps/api/src/mcp/endpoint.ts index c68a186..078373c 100644 --- a/apps/api/src/mcp/endpoint.ts +++ b/apps/api/src/mcp/endpoint.ts @@ -1,7 +1,7 @@ import { ActionResult, endpoint, type Handler } from '@cleverbrush/server'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { PrincipalSchema } from '@xpenser/contracts'; -import { DbToken, LoggerToken } from '../di/tokens.js'; +import { ConfigToken, DbToken, KnexToken, LoggerToken } from '../di/tokens.js'; import { McpTransportError } from '../log-templates.js'; import { requireMcpApiKeyPrincipal } from './auth.js'; import { createXpenserMcpServer } from './server.js'; @@ -9,15 +9,20 @@ import { createXpenserMcpServer } from './server.js'; export const McpEndpoint = endpoint .post('/api/mcp') .authorize(PrincipalSchema) - .inject({ db: DbToken, logger: LoggerToken }) + .inject({ + config: ConfigToken, + db: DbToken, + knex: KnexToken, + logger: LoggerToken + }) .summary('MCP server') - .description('Read-only xpenser MCP server for AI agents.') + .description('xpenser MCP server for AI agents.') .tags('mcp') .operationId('xpenserMcp'); export const mcpHandler: Handler = async ( { context, principal }, - { db, logger } + { config, db, knex, logger } ) => { let apiKeyPrincipal: ReturnType; try { @@ -30,7 +35,9 @@ export const mcpHandler: Handler = async ( } const mcpServer = createXpenserMcpServer({ + config, db, + knex, logger, principal: apiKeyPrincipal }); diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index 6aa3c7b..9f9a36d 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -1,5 +1,7 @@ import type { Logger } from '@cleverbrush/log'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Knex } from 'knex'; +import type { Config } from '../config.js'; import type { AppDb } from '../db/schemas.js'; import type { McpApiKeyPrincipal } from './auth.js'; import { @@ -8,13 +10,17 @@ import { } from './tools.js'; export type XpenserMcpServerOptions = { + readonly config: Config; readonly db: AppDb; + readonly knex: Knex; readonly logger: Logger; readonly principal: McpApiKeyPrincipal; }; export function createXpenserMcpServer({ + config, db, + knex, logger, principal }: XpenserMcpServerOptions): Server { @@ -26,7 +32,7 @@ export function createXpenserMcpServer({ registerXpenserMcpTools(server, { principal, logger, - data: createXpenserMcpDataAccess(db) + data: createXpenserMcpDataAccess(db, config, knex) }); return server; diff --git a/apps/api/src/mcp/tools.test.ts b/apps/api/src/mcp/tools.test.ts index c0f5f36..5879d46 100644 --- a/apps/api/src/mcp/tools.test.ts +++ b/apps/api/src/mcp/tools.test.ts @@ -1,16 +1,34 @@ +import { ErrorCode, type McpError } from '@modelcontextprotocol/sdk/types.js'; import type { + Category, DashboardSummary, StatsOverview, - TransactionListQuery + Transaction, + TransactionListQuery, + Vendor, + VendorCandidate } from '@xpenser/contracts'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TransactionCategoryError } from '../application/transactions.js'; +import { VendorNameError } from '../application/vendors.js'; import type { McpApiKeyPrincipal } from './auth.js'; import { + createXpenserMcpTools, + handleCreateCategory, + handleCreateTransaction, + handleDeleteCategory, + handleDeleteTransaction, handleGetCurrentUser, handleGetDashboardSummary, handleGetStatsOverview, + handleGetVendorCandidateDetails, handleListCategories, handleListTransactions, + handleMoveAndDeleteCategory, + handleSearchVendorCandidates, + handleUpdateTransaction, + handleUpdateVendor, + normalizeCreateTransactionInput, normalizeTransactionListInput, serializeMcpData, type XpenserMcpDataAccess, @@ -24,6 +42,73 @@ const principal: McpApiKeyPrincipal = { apiKeyId: 42 }; +function category(overrides: Partial = {}): Category { + return { + id: 1, + name: 'Food', + type: 'expense', + parentId: null, + kind: 'normal', + displayName: 'Food', + inUse: true, + hasChildren: false, + archivedAt: null, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-02T00:00:00.000Z'), + ...overrides + }; +} + +function vendor(overrides: Partial = {}): Vendor { + return { + id: 5, + name: 'Store', + displayName: 'Store', + domain: 'store.example', + transactionCount: 1, + createdAt: new Date('2026-05-03T00:00:00.000Z'), + updatedAt: new Date('2026-05-04T00:00:00.000Z'), + ...overrides + }; +} + +function vendorCandidate( + overrides: Partial = {} +): VendorCandidate { + return { + brandfetchBrandId: 'brand_1', + name: 'Store', + domain: 'store.example', + logoUrl: 'https://store.example/logo.svg', + ...overrides + }; +} + +function transaction(overrides: Partial = {}): Transaction { + return { + id: 100, + categoryId: 1, + vendorId: null, + categoryName: 'Food', + categoryDisplayName: 'Food', + categoryParentId: null, + categoryKind: 'normal', + type: 'expense', + amount: 12.34, + currency: 'USD', + defaultCurrencyAmount: 12.34, + defaultCurrency: 'USD', + exchangeRate: 1, + exchangeRateDate: '2026-05-14', + occurredAt: new Date('2026-05-14T00:00:00.000Z'), + note: 'Lunch', + scanAttachment: null, + createdAt: new Date('2026-05-14T01:00:00.000Z'), + updatedAt: new Date('2026-05-14T01:00:00.000Z'), + ...overrides + }; +} + function statsOverview(): StatsOverview { return { groupBy: 'day', @@ -103,62 +188,146 @@ describe('MCP tool helpers', () => { weeklyEmailReportEnabled: true, monthlyEmailReportEnabled: true })), - listCategories: vi.fn(async () => [ - { - id: 1, - name: 'Food', - type: 'expense' as const, - parentId: null, - kind: 'normal' as const, - displayName: 'Food', - inUse: true, - hasChildren: false, - archivedAt: null, - createdAt: new Date('2026-05-01T00:00:00.000Z'), - updatedAt: new Date('2026-05-02T00:00:00.000Z') - } - ]), + listCategories: vi.fn(async () => [category()]), + createCategory: vi.fn(async (_userId, body) => + category({ + id: 2, + name: body.name, + type: body.type, + parentId: body.parentId ?? null, + kind: body.kind ?? 'normal', + displayName: body.name, + inUse: false + }) + ), + updateCategory: vi.fn(async (_userId, id, body) => + category({ + id, + name: body.name ?? 'Food', + archivedAt: body.archived + ? new Date('2026-05-05T00:00:00.000Z') + : null + }) + ), + deleteCategory: vi.fn(async () => undefined), + moveAndDeleteCategory: vi.fn(async () => undefined), + listVendors: vi.fn(async () => [vendor()]), + getVendor: vi.fn(async (_userId, id) => vendor({ id })), + searchVendorCandidates: vi.fn(async () => [vendorCandidate()]), + getVendorCandidateDetails: vi.fn(async query => + query.domain + ? vendorCandidate({ domain: query.domain }) + : undefined + ), + createVendor: vi.fn(async (_userId, body) => + vendor({ + id: 6, + name: body.name, + displayName: body.name, + domain: body.domain + }) + ), + updateVendor: vi.fn(async (_userId, id, body) => + vendor({ + id, + name: body.name ?? 'Store', + displayName: body.name ?? 'Store', + domain: body.domain ?? undefined + }) + ), + enrichVendor: vi.fn(async (_userId, id) => + vendor({ id, enrichmentStatus: 'success' }) + ), listTransactions: vi.fn( async (_userId: number, query: TransactionListQuery) => ({ items: [ - { - id: 100, - categoryId: 1, - vendorId: null, - categoryName: 'Food', - categoryDisplayName: 'Food', - categoryParentId: null, - categoryKind: 'normal' as const, - type: 'expense' as const, - amount: 12.34, - currency: 'USD', - defaultCurrencyAmount: 12.34, - defaultCurrency: 'USD', - exchangeRate: 1, - exchangeRateDate: '2026-05-14', + transaction({ occurredAt: query.from ?? - new Date('2026-05-14T00:00:00.000Z'), - note: 'Lunch', - createdAt: new Date('2026-05-14T01:00:00.000Z'), - updatedAt: new Date('2026-05-14T01:00:00.000Z') - } + new Date('2026-05-14T00:00:00.000Z') + }) ], total: 1, page: query.page ?? 1, limit: query.limit ?? 50 }) ), + createTransaction: vi.fn(async (_userId, body) => + transaction({ + id: 101, + categoryId: body.categoryId, + vendorId: body.vendorId ?? null, + amount: body.amount, + currency: body.currency, + occurredAt: body.occurredAt, + note: body.note + }) + ), + updateTransaction: vi.fn(async (_userId, id, body) => + transaction({ + id, + categoryId: body.categoryId ?? 1, + vendorId: body.vendorId ?? null, + amount: body.amount ?? 12.34, + currency: body.currency ?? 'USD', + occurredAt: + body.occurredAt ?? new Date('2026-05-14T00:00:00.000Z'), + note: body.note + }) + ), + deleteTransaction: vi.fn(async () => undefined), getDashboardSummary: vi.fn(async () => dashboardSummary()), getStatsOverview: vi.fn(async () => statsOverview()) }; context = { principal, data, - logger: { info: vi.fn() } + logger: { info: vi.fn(), warn: vi.fn() } }; }); + it('advertises read, write, destructive, and open-world annotations', () => { + const tools = createXpenserMcpTools(context); + + expect(tools.map(tool => tool.name)).toEqual([ + 'xpenser_get_current_user', + 'xpenser_list_categories', + 'xpenser_create_category', + 'xpenser_update_category', + 'xpenser_delete_category', + 'xpenser_move_and_delete_category', + 'xpenser_list_vendors', + 'xpenser_get_vendor', + 'xpenser_search_vendor_candidates', + 'xpenser_get_vendor_candidate_details', + 'xpenser_create_vendor', + 'xpenser_update_vendor', + 'xpenser_enrich_vendor', + 'xpenser_list_transactions', + 'xpenser_create_transaction', + 'xpenser_update_transaction', + 'xpenser_delete_transaction', + 'xpenser_get_dashboard_summary', + 'xpenser_get_stats_overview' + ]); + expect( + tools.find(tool => tool.name === 'xpenser_search_vendor_candidates') + ?.annotations + ).toMatchObject({ readOnlyHint: true, openWorldHint: true }); + expect( + tools.find(tool => tool.name === 'xpenser_create_transaction') + ?.annotations + ).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true + }); + expect( + tools.find(tool => tool.name === 'xpenser_delete_transaction') + ?.annotations + ).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + }); + it('normalizes transaction pagination and dates', () => { const query = normalizeTransactionListInput({ search: ' food ', @@ -177,6 +346,17 @@ describe('MCP tool helpers', () => { expect(query.from).toEqual(new Date('2026-05-01T00:00:00.000Z')); }); + it('normalizes transaction creation dates', () => { + expect( + normalizeCreateTransactionInput({ + categoryId: 1, + amount: 10, + currency: 'USD', + occurredAt: '2026-05-10T12:00:00.000Z' + }).occurredAt + ).toEqual(new Date('2026-05-10T12:00:00.000Z')); + }); + it('serializes dates to ISO strings for MCP structured content', () => { expect( serializeMcpData({ @@ -198,15 +378,93 @@ describe('MCP tool helpers', () => { }); }); - it('delegates category reads to the authenticated user', async () => { - const result = await handleListCategories(context); + it('delegates category reads with MCP filters', async () => { + const result = await handleListCategories(context, { + activeOnly: true, + sort: 'recent-transaction-count' + }); - expect(data.listCategories).toHaveBeenCalledWith(7); + expect(data.listCategories).toHaveBeenCalledWith(7, { + activeOnly: true, + sort: 'recent-transaction-count' + }); expect(result.structuredContent).toMatchObject({ categories: [{ id: 1, createdAt: '2026-05-01T00:00:00.000Z' }] }); }); + it('delegates category writes and delete cleanup responses', async () => { + const created = await handleCreateCategory(context, { + name: 'Books', + type: 'expense' + }); + const deleted = await handleDeleteCategory(context, { id: 2 }); + const moved = await handleMoveAndDeleteCategory(context, { + id: 3, + replacementCategoryId: 4 + }); + + expect(data.createCategory).toHaveBeenCalledWith(7, { + name: 'Books', + type: 'expense' + }); + expect(created.structuredContent).toMatchObject({ + category: { id: 2, name: 'Books' } + }); + expect(data.deleteCategory).toHaveBeenCalledWith(7, 2); + expect(deleted.structuredContent).toEqual({ deleted: true, id: 2 }); + expect(data.moveAndDeleteCategory).toHaveBeenCalledWith(7, 3, 4); + expect(moved.structuredContent).toEqual({ + deleted: true, + id: 3, + replacementCategoryId: 4 + }); + }); + + it('delegates vendor candidate tools', async () => { + const candidates = await handleSearchVendorCandidates(context, { + query: 'Store', + limit: 3 + }); + const details = await handleGetVendorCandidateDetails(context, { + domain: 'store.example' + }); + const missingDetails = await handleGetVendorCandidateDetails(context, { + brandfetchBrandId: 'missing' + }); + + expect(data.searchVendorCandidates).toHaveBeenCalledWith({ + query: 'Store', + limit: 3 + }); + expect(candidates.structuredContent).toMatchObject({ + vendorCandidates: [{ name: 'Store' }] + }); + expect(details.structuredContent).toMatchObject({ + vendorCandidate: { domain: 'store.example' } + }); + expect(missingDetails.structuredContent).toEqual({ + vendorCandidate: null + }); + }); + + it('maps expected vendor write errors to MCP invalid params', async () => { + vi.mocked(data.updateVendor).mockRejectedValueOnce( + new VendorNameError('A vendor with this name already exists.') + ); + + await expect( + handleUpdateVendor(context, { id: 5, name: 'Store' }) + ).rejects.toSatisfy( + (error: McpError) => + error.code === ErrorCode.InvalidParams && + error.message.includes( + 'A vendor with this name already exists.' + ) + ); + expect(context.logger.warn).toHaveBeenCalled(); + }); + it('delegates transaction reads with capped query params', async () => { const result = await handleListTransactions(context, { from: '2026-05-14T00:00:00.000Z', @@ -234,6 +492,73 @@ describe('MCP tool helpers', () => { }); }); + it('delegates transaction writes with normalized dates', async () => { + const created = await handleCreateTransaction(context, { + categoryId: 1, + vendorId: 5, + amount: 12.34, + currency: 'USD', + occurredAt: '2026-05-15T00:00:00.000Z', + note: 'Dinner' + }); + const updated = await handleUpdateTransaction(context, { + id: 101, + amount: 20, + occurredAt: '2026-05-16T00:00:00.000Z', + note: '' + }); + const deleted = await handleDeleteTransaction(context, { id: 101 }); + + expect(data.createTransaction).toHaveBeenCalledWith( + 7, + expect.objectContaining({ + categoryId: 1, + vendorId: 5, + occurredAt: new Date('2026-05-15T00:00:00.000Z') + }) + ); + expect(context.logger.info).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ TransactionId: 101, UserId: 7 }) + ); + expect(created.structuredContent).toMatchObject({ + transaction: { id: 101, note: 'Dinner' } + }); + expect(data.updateTransaction).toHaveBeenCalledWith( + 7, + 101, + expect.objectContaining({ + amount: 20, + occurredAt: new Date('2026-05-16T00:00:00.000Z'), + note: '' + }) + ); + expect(updated.structuredContent).toMatchObject({ + transaction: { id: 101, note: '' } + }); + expect(data.deleteTransaction).toHaveBeenCalledWith(7, 101); + expect(deleted.structuredContent).toEqual({ deleted: true, id: 101 }); + }); + + it('maps expected transaction write errors to MCP invalid params', async () => { + vi.mocked(data.createTransaction).mockRejectedValueOnce( + new TransactionCategoryError('Category was not found.') + ); + + await expect( + handleCreateTransaction(context, { + categoryId: 999, + amount: 12, + currency: 'USD', + occurredAt: '2026-05-15T00:00:00.000Z' + }) + ).rejects.toSatisfy( + (error: McpError) => + error.code === ErrorCode.InvalidParams && + error.message.includes('Category was not found.') + ); + }); + it('delegates dashboard summary reads with period controls', async () => { const result = await handleGetDashboardSummary(context, { period: 'month', diff --git a/apps/api/src/mcp/tools.ts b/apps/api/src/mcp/tools.ts index 21fe326..a9f949a 100644 --- a/apps/api/src/mcp/tools.ts +++ b/apps/api/src/mcp/tools.ts @@ -1,5 +1,6 @@ import type { Logger } from '@cleverbrush/log'; import { + boolean, enumOf, type InferType, number, @@ -18,23 +19,68 @@ import { McpError, type Tool } from '@modelcontextprotocol/sdk/types.js'; -import type { - Category, - DashboardSummary, - StatsOverview, - StatsQuery, - TransactionListQuery, - UserPreference +import { + type Category, + type CategoryListQuery, + type CreateCategoryBody, + type CreateTransactionBody, + type CreateVendorBody, + type DashboardSummary, + FieldLimits, + type StatsOverview, + type StatsQuery, + type Transaction, + type TransactionListQuery, + type UpdateVendorBody, + type UserPreference, + type Vendor, + type VendorCandidate, + type VendorCandidateDetailsQuery, + type VendorCandidateSearchQuery, + type VendorListQuery } from '@xpenser/contracts'; -import { listCategories as listUserCategories } from '../application/categories.js'; +import type { Knex } from 'knex'; +import { + CategoryHierarchyError, + CategoryInUseError, + CategoryNotFoundError, + createCategory as createUserCategory, + deleteCategory as deleteUserCategory, + LastCategoryError, + listCategories as listUserCategories, + moveAndDeleteCategory as moveAndDeleteUserCategory, + updateCategory as updateUserCategory +} from '../application/categories.js'; import { + createTransaction as createUserTransaction, dashboardSummary, + deleteTransaction as deleteUserTransaction, listTransactions, - statsOverview + statsOverview, + TransactionCategoryError, + TransactionNotFoundError, + updateTransaction as updateUserTransaction } from '../application/transactions.js'; import { getUserPreference } from '../application/users.js'; +import { + createVendor as createUserVendor, + getVendorCandidateDetails, + getVendorDetails, + listVendors, + retryVendorEnrichment, + searchVendorCandidates, + updateVendor as updateUserVendor, + VendorMetadataError, + VendorNameError, + VendorNotFoundError +} from '../application/vendors.js'; +import type { Config } from '../config.js'; import type { AppDb } from '../db/schemas.js'; -import { McpToolCalled } from '../log-templates.js'; +import { + McpToolCalled, + TransactionCreated, + VendorUpdateValidationRejected +} from '../log-templates.js'; import type { McpApiKeyPrincipal } from './auth.js'; type JsonValue = @@ -46,6 +92,18 @@ type JsonValue = | { readonly [key: string]: JsonValue }; type TransactionListResult = Awaited>; +type UpdateCategoryBody = Partial & { + readonly archived?: boolean; +}; +type UpdateTransactionBody = Partial; +type McpUpdateVendorBody = { + readonly name?: string; + readonly resolvedName?: string | null; + readonly domain?: string | null; + readonly description?: string | null; + readonly logoUrl?: string | null; + readonly primaryColor?: string | null; +}; type AnyObjectSchema = ObjectSchemaBuilder; type ToolAnnotations = NonNullable; type ToolInputSchema = Tool['inputSchema']; @@ -65,11 +123,69 @@ export type XpenserMcpDataAccess = { readonly getCurrentUser: ( userId: number ) => Promise; - readonly listCategories: (userId: number) => Promise; + readonly listCategories: ( + userId: number, + query: CategoryListQuery + ) => Promise; + readonly createCategory: ( + userId: number, + body: CreateCategoryBody + ) => Promise; + readonly updateCategory: ( + userId: number, + categoryId: number, + body: UpdateCategoryBody + ) => Promise; + readonly deleteCategory: ( + userId: number, + categoryId: number + ) => Promise; + readonly moveAndDeleteCategory: ( + userId: number, + categoryId: number, + replacementCategoryId: number + ) => Promise; + readonly listVendors: ( + userId: number, + query: VendorListQuery + ) => Promise; + readonly getVendor: (userId: number, vendorId: number) => Promise; + readonly searchVendorCandidates: ( + query: VendorCandidateSearchQuery + ) => Promise; + readonly getVendorCandidateDetails: ( + query: VendorCandidateDetailsQuery + ) => Promise; + readonly createVendor: ( + userId: number, + body: CreateVendorBody + ) => Promise; + readonly updateVendor: ( + userId: number, + vendorId: number, + body: McpUpdateVendorBody + ) => Promise; + readonly enrichVendor: ( + userId: number, + vendorId: number + ) => Promise; readonly listTransactions: ( userId: number, query: TransactionListQuery ) => Promise; + readonly createTransaction: ( + userId: number, + body: CreateTransactionBody + ) => Promise; + readonly updateTransaction: ( + userId: number, + transactionId: number, + body: UpdateTransactionBody + ) => Promise; + readonly deleteTransaction: ( + userId: number, + transactionId: number + ) => Promise; readonly getDashboardSummary: ( userId: number, period: DashboardSummary['period'], @@ -84,7 +200,7 @@ export type XpenserMcpDataAccess = { export type XpenserMcpToolContext = { readonly principal: McpApiKeyPrincipal; readonly data: XpenserMcpDataAccess; - readonly logger: Pick; + readonly logger: Pick; }; const readOnlyAnnotations = { @@ -94,6 +210,35 @@ const readOnlyAnnotations = { openWorldHint: false } as const; +const openWorldReadOnlyAnnotations = { + ...readOnlyAnnotations, + openWorldHint: true +} as const; + +const additiveWriteAnnotations = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false +} as const; + +const openWorldAdditiveWriteAnnotations = { + ...additiveWriteAnnotations, + openWorldHint: true +} as const; + +const destructiveWriteAnnotations = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false +} as const; + +const openWorldDestructiveWriteAnnotations = { + ...destructiveWriteAnnotations, + openWorldHint: true +} as const; + const EmptyInputSchema = object({}); const dateString = string() @@ -101,25 +246,229 @@ const dateString = string() .nonempty() .describe('ISO 8601 date or timestamp.'); -const TransactionListInputSchema = object({ - search: string() +const currencyCode = string() + .trim() + .matches(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO 4217 code') + .describe('ISO 4217 currency code, for example USD or EUR.'); + +const amount = number() + .clearIsInteger() + .positive() + .describe('Positive amount with at most two decimal places.') + .addValidator(value => { + const scaled = value * 100; + const nearestCent = Math.round(scaled); + const tolerance = Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8; + if (Math.abs(scaled - nearestCent) <= tolerance) { + return { valid: true }; + } + + return { + valid: false, + errors: [{ message: 'amount can have at most two decimal places' }] + }; + }); + +function id(description: string) { + return number().isInteger().positive().describe(description); +} + +function optionalText(description: string, maxLength: number) { + return string() + .trim() + .maxLength(maxLength) + .optional() + .describe(description); +} + +function nullableText(description: string, maxLength: number) { + return string() + .trim() + .maxLength(maxLength) + .nullable() + .optional() + .describe(description); +} + +const CategoryListInputSchema = object({ + sort: enumOf('recent-transaction-count') + .optional() + .describe('Optional category ordering mode.'), + activeOnly: boolean() + .optional() + .describe( + 'True to return only categories available for new transactions.' + ) +}); + +const CreateCategoryInputSchema = object({ + name: string() .trim() + .nonempty() + .maxLength(FieldLimits.categoryName) + .describe('Category name shown in transaction forms and reports.'), + type: enumOf('expense', 'income').describe( + 'Whether this category is for expenses or income.' + ), + parentId: id('Optional parent category identifier for one-level nesting.') + .nullable() + .optional(), + kind: enumOf('normal', 'offset') .optional() - .describe('Text search across category names and notes.'), + .describe( + 'Whether transactions in this category report on the same or opposite side.' + ) +}); + +const UpdateCategoryInputSchema = object({ + id: id('Category identifier to update.'), + name: string() + .trim() + .nonempty() + .maxLength(FieldLimits.categoryName) + .optional() + .describe('Updated category name.'), type: enumOf('expense', 'income') .optional() - .describe('Filter by transaction direction.'), - categoryId: number() + .describe('Updated category type.'), + parentId: id('Updated parent category identifier, or null for top-level.') + .nullable() + .optional(), + kind: enumOf('normal', 'offset') + .optional() + .describe('Updated category reporting kind.'), + archived: boolean() + .optional() + .describe('Whether this category should be archived or restored.') +}); + +const CategoryIdInputSchema = object({ + id: id('Category identifier.') +}); + +const MoveAndDeleteCategoryInputSchema = object({ + id: id('Category identifier to delete.'), + replacementCategoryId: id( + 'Category that should receive transactions before deleting the selected category.' + ) +}); + +const VendorListInputSchema = object({ + search: optionalText( + 'Text search applied to vendor names, domains, and descriptions.', + FieldLimits.vendorSearch + ), + limit: number() .isInteger() .positive() .optional() - .describe('Filter by category identifier.'), - parentCategoryId: number() + .describe('Maximum number of vendors to return. Defaults to 25.') +}); + +const VendorIdInputSchema = object({ + id: id('Vendor identifier.') +}); + +const VendorCandidateSearchInputSchema = object({ + query: string() + .trim() + .nonempty() + .maxLength(FieldLimits.vendorSearch) + .describe('Vendor name text to search through Brandfetch.'), + limit: number() .isInteger() .positive() .optional() - .describe('Filter by a parent category and its direct children.'), - vendorId: union(number().isInteger().positive()) + .describe('Maximum number of Brandfetch suggestions to return.') +}); + +const VendorCandidateDetailsInputSchema = object({ + brandfetchBrandId: optionalText( + 'Brandfetch brand identifier.', + FieldLimits.brandfetchBrandId + ), + domain: optionalText( + 'Vendor domain returned by Brandfetch.', + FieldLimits.vendorDomain + ) +}).addValidator(value => { + if (value.brandfetchBrandId || value.domain) { + return { valid: true }; + } + + return { + valid: false, + errors: [ + { message: 'vendor candidate details require a brand ID or domain' } + ] + }; +}); + +const CreateVendorInputSchema = object({ + name: string() + .trim() + .nonempty() + .maxLength(FieldLimits.vendorName) + .describe('User-entered vendor name.'), + brandfetchBrandId: optionalText( + 'Brandfetch brand identifier selected from search results.', + FieldLimits.brandfetchBrandId + ), + resolvedName: optionalText( + 'Resolved name selected from Brandfetch search results.', + FieldLimits.vendorName + ), + domain: optionalText( + 'Vendor domain selected from Brandfetch search results.', + FieldLimits.vendorDomain + ), + logoUrl: optionalText( + 'Vendor logo URL selected from Brandfetch search results.', + FieldLimits.vendorLogoUrl + ) +}); + +const UpdateVendorInputSchema = object({ + id: id('Vendor identifier to update.'), + name: optionalText( + 'Updated user-entered vendor name.', + FieldLimits.vendorName + ), + resolvedName: nullableText( + 'Manually adjusted resolved name, or null to clear it.', + FieldLimits.vendorName + ), + domain: nullableText( + 'Manually adjusted vendor domain, or null to clear it.', + FieldLimits.vendorDomain + ), + description: nullableText( + 'Manually adjusted vendor description, or null to clear it.', + FieldLimits.vendorDescription + ), + logoUrl: nullableText( + 'Manually adjusted HTTPS logo URL, or null to clear it.', + FieldLimits.vendorLogoUrl + ), + primaryColor: nullableText( + 'Manually adjusted six-digit hex color, or null to clear it.', + FieldLimits.vendorPrimaryColor + ) +}); + +const TransactionListInputSchema = object({ + search: string() + .trim() + .optional() + .describe('Text search across category names, vendors, and notes.'), + type: enumOf('expense', 'income') + .optional() + .describe('Filter by transaction direction.'), + categoryId: id('Filter by category identifier.').optional(), + parentCategoryId: id( + 'Filter by a parent category and its direct children.' + ).optional(), + vendorId: union(id('Filter by vendor identifier.')) .or(string('none')) .optional() .describe( @@ -131,21 +480,50 @@ const TransactionListInputSchema = object({ to: dateString .optional() .describe('Inclusive occurrence end date or timestamp.'), - page: number() - .isInteger() - .positive() - .optional() - .describe('One-based page number. Defaults to 1.'), - limit: number() - .isInteger() - .positive() - .optional() - .describe('Page size. Defaults to 50 and is capped at 100.'), + page: id('One-based page number. Defaults to 1.').optional(), + limit: id('Page size. Defaults to 50 and is capped at 100.').optional(), direction: enumOf('asc', 'desc') .optional() .describe('Sort direction by occurrence date. Defaults to desc.') }); +const CreateTransactionInputSchema = object({ + categoryId: id('Category identifier selected for the transaction.'), + vendorId: id('Optional vendor identifier selected for the transaction.') + .nullable() + .optional(), + amount, + currency: currencyCode, + occurredAt: dateString.describe( + 'Date and time when the transaction happened.' + ), + note: string() + .maxLength(FieldLimits.transactionNote) + .optional() + .describe('Optional note entered by the user.') +}); + +const UpdateTransactionInputSchema = object({ + id: id('Transaction identifier to update.'), + categoryId: id('Updated category identifier.').optional(), + vendorId: id('Updated vendor identifier, or null to remove the vendor.') + .nullable() + .optional(), + amount: amount.optional(), + currency: currencyCode.optional(), + occurredAt: dateString + .optional() + .describe('Updated transaction occurrence date or timestamp.'), + note: string() + .maxLength(FieldLimits.transactionNote) + .optional() + .describe('Updated note. Pass an empty string to clear the note.') +}); + +const TransactionIdInputSchema = object({ + id: id('Transaction identifier.') +}); + const DashboardInputSchema = object({ period: enumOf('day', 'week', 'month', 'quarter', 'year') .optional() @@ -183,22 +561,80 @@ const StatsInputSchema = object({ .describe('Date used to choose the dashboard-style reporting period.') }); +type CategoryListInput = InferType; +type CreateCategoryInput = InferType; +type UpdateCategoryInput = InferType; +type CategoryIdInput = InferType; +type MoveAndDeleteCategoryInput = InferType< + typeof MoveAndDeleteCategoryInputSchema +>; +type VendorListInput = InferType; +type VendorIdInput = InferType; +type VendorCandidateSearchInput = InferType< + typeof VendorCandidateSearchInputSchema +>; +type VendorCandidateDetailsInput = InferType< + typeof VendorCandidateDetailsInputSchema +>; +type CreateVendorInput = InferType; +type UpdateVendorInput = InferType; type TransactionListInput = InferType; +type CreateTransactionInput = InferType; +type UpdateTransactionInput = InferType; +type TransactionIdInput = InferType; type DashboardInput = InferType; type StatsInput = InferType; -export function createXpenserMcpDataAccess(db: AppDb): XpenserMcpDataAccess { +export function createXpenserMcpDataAccess( + db: AppDb, + config: Config, + knex: Knex +): XpenserMcpDataAccess { return { getCurrentUser: userId => getUserPreference(db, userId), - listCategories: userId => listUserCategories(db, userId), + listCategories: (userId, query) => + listUserCategories(db, userId, query), + createCategory: (userId, body) => createUserCategory(db, userId, body), + updateCategory: (userId, categoryId, body) => + updateUserCategory(db, userId, categoryId, body), + deleteCategory: (userId, categoryId) => + deleteUserCategory(db, userId, categoryId), + moveAndDeleteCategory: (userId, categoryId, replacementCategoryId) => + moveAndDeleteUserCategory( + db, + userId, + categoryId, + replacementCategoryId + ), + listVendors: (userId, query) => listVendors(db, userId, query), + getVendor: (userId, vendorId) => getVendorDetails(db, userId, vendorId), + searchVendorCandidates: query => searchVendorCandidates(config, query), + getVendorCandidateDetails: query => + getVendorCandidateDetails(config, query), + createVendor: (userId, body) => + createUserVendor(db, config, userId, body), + updateVendor: (userId, vendorId, body) => + updateUserVendor(db, userId, vendorId, body as UpdateVendorBody), + enrichVendor: (userId, vendorId) => + retryVendorEnrichment(db, config, userId, vendorId), listTransactions: (userId, query) => - listTransactions(db, userId, query), + listTransactions(db, userId, query, knex), + createTransaction: (userId, body) => + createUserTransaction(db, config, userId, body), + updateTransaction: (userId, transactionId, body) => + updateUserTransaction(db, config, userId, transactionId, body), + deleteTransaction: (userId, transactionId) => + deleteUserTransaction(db, userId, transactionId), getDashboardSummary: (userId, period, date) => dashboardSummary(db, userId, period, date), getStatsOverview: (userId, query) => statsOverview(db, userId, query) }; } +function invalidParams(message: string): McpError { + return new McpError(ErrorCode.InvalidParams, message); +} + function parseOptionalDate(value: string | undefined, field: string) { if (!value) { return undefined; @@ -206,17 +642,48 @@ function parseOptionalDate(value: string | undefined, field: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { - throw new Error(`${field} must be a valid date or timestamp.`); + throw invalidParams(`${field} must be a valid date or timestamp.`); } return date; } +function parseRequiredDate(value: string, field: string): Date { + return parseOptionalDate(value, field) ?? new Date(value); +} + function nonempty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } +export function normalizeCategoryListInput( + input: CategoryListInput +): CategoryListQuery { + return { + sort: input.sort, + activeOnly: input.activeOnly + }; +} + +export function normalizeVendorListInput( + input: VendorListInput +): VendorListQuery { + return { + search: nonempty(input.search), + limit: input.limit ?? 25 + }; +} + +export function normalizeVendorCandidateSearchInput( + input: VendorCandidateSearchInput +): VendorCandidateSearchQuery { + return { + query: input.query, + limit: input.limit ?? 6 + }; +} + export function normalizeTransactionListInput( input: TransactionListInput ): TransactionListQuery { @@ -237,6 +704,32 @@ export function normalizeTransactionListInput( }; } +export function normalizeCreateTransactionInput( + input: CreateTransactionInput +): CreateTransactionBody { + return { + categoryId: input.categoryId, + vendorId: input.vendorId, + amount: input.amount, + currency: input.currency, + occurredAt: parseRequiredDate(input.occurredAt, 'occurredAt'), + note: input.note + }; +} + +export function normalizeUpdateTransactionInput( + input: UpdateTransactionInput +): UpdateTransactionBody { + return { + categoryId: input.categoryId, + vendorId: input.vendorId, + amount: input.amount, + currency: input.currency, + occurredAt: parseOptionalDate(input.occurredAt, 'occurredAt'), + note: input.note + }; +} + export function normalizeStatsInput(input: StatsInput): StatsQuery { return { groupBy: input.groupBy ?? 'day', @@ -332,8 +825,7 @@ async function validateToolInput( return (result.object ?? {}) as Record; } - throw new McpError( - ErrorCode.InvalidParams, + throw invalidParams( `Input validation error: Invalid arguments for tool ${toolName}: ${validationErrorMessage(result)}` ); } @@ -346,6 +838,27 @@ function logToolCall(context: XpenserMcpToolContext, toolName: string): void { }); } +function mapExpectedError(err: unknown): never { + if (err instanceof McpError) { + throw err; + } + if ( + err instanceof CategoryHierarchyError || + err instanceof CategoryInUseError || + err instanceof CategoryNotFoundError || + err instanceof LastCategoryError || + err instanceof TransactionCategoryError || + err instanceof TransactionNotFoundError || + err instanceof VendorMetadataError || + err instanceof VendorNameError || + err instanceof VendorNotFoundError + ) { + throw invalidParams(err.message); + } + + throw err; +} + export async function handleGetCurrentUser( context: XpenserMcpToolContext ): Promise { @@ -360,15 +873,216 @@ export async function handleGetCurrentUser( } export async function handleListCategories( - context: XpenserMcpToolContext + context: XpenserMcpToolContext, + input: CategoryListInput = {} ): Promise { const toolName = 'xpenser_list_categories'; logToolCall(context, toolName); return toolResult({ - categories: await context.data.listCategories(context.principal.userId) + categories: await context.data.listCategories( + context.principal.userId, + normalizeCategoryListInput(input) + ) + }); +} + +export async function handleCreateCategory( + context: XpenserMcpToolContext, + input: CreateCategoryInput +): Promise { + const toolName = 'xpenser_create_category'; + logToolCall(context, toolName); + try { + return toolResult({ + category: await context.data.createCategory( + context.principal.userId, + input + ) + }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleUpdateCategory( + context: XpenserMcpToolContext, + input: UpdateCategoryInput +): Promise { + const toolName = 'xpenser_update_category'; + logToolCall(context, toolName); + const { id: categoryId, ...body } = input; + try { + return toolResult({ + category: await context.data.updateCategory( + context.principal.userId, + categoryId, + body + ) + }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleDeleteCategory( + context: XpenserMcpToolContext, + input: CategoryIdInput +): Promise { + const toolName = 'xpenser_delete_category'; + logToolCall(context, toolName); + try { + await context.data.deleteCategory(context.principal.userId, input.id); + return toolResult({ deleted: true, id: input.id }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleMoveAndDeleteCategory( + context: XpenserMcpToolContext, + input: MoveAndDeleteCategoryInput +): Promise { + const toolName = 'xpenser_move_and_delete_category'; + logToolCall(context, toolName); + try { + await context.data.moveAndDeleteCategory( + context.principal.userId, + input.id, + input.replacementCategoryId + ); + return toolResult({ + deleted: true, + id: input.id, + replacementCategoryId: input.replacementCategoryId + }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleListVendors( + context: XpenserMcpToolContext, + input: VendorListInput +): Promise { + const toolName = 'xpenser_list_vendors'; + logToolCall(context, toolName); + return toolResult({ + vendors: await context.data.listVendors( + context.principal.userId, + normalizeVendorListInput(input) + ) }); } +export async function handleGetVendor( + context: XpenserMcpToolContext, + input: VendorIdInput +): Promise { + const toolName = 'xpenser_get_vendor'; + logToolCall(context, toolName); + try { + return toolResult({ + vendor: await context.data.getVendor( + context.principal.userId, + input.id + ) + }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleSearchVendorCandidates( + context: XpenserMcpToolContext, + input: VendorCandidateSearchInput +): Promise { + const toolName = 'xpenser_search_vendor_candidates'; + logToolCall(context, toolName); + return toolResult({ + vendorCandidates: await context.data.searchVendorCandidates( + normalizeVendorCandidateSearchInput(input) + ) + }); +} + +export async function handleGetVendorCandidateDetails( + context: XpenserMcpToolContext, + input: VendorCandidateDetailsInput +): Promise { + const toolName = 'xpenser_get_vendor_candidate_details'; + logToolCall(context, toolName); + return toolResult({ + vendorCandidate: + (await context.data.getVendorCandidateDetails(input)) ?? null + }); +} + +export async function handleCreateVendor( + context: XpenserMcpToolContext, + input: CreateVendorInput +): Promise { + const toolName = 'xpenser_create_vendor'; + logToolCall(context, toolName); + try { + return toolResult({ + vendor: await context.data.createVendor( + context.principal.userId, + input + ) + }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleUpdateVendor( + context: XpenserMcpToolContext, + input: UpdateVendorInput +): Promise { + const toolName = 'xpenser_update_vendor'; + logToolCall(context, toolName); + const { id: vendorId, ...body } = input; + try { + return toolResult({ + vendor: await context.data.updateVendor( + context.principal.userId, + vendorId, + body + ) + }); + } catch (err) { + if ( + err instanceof VendorMetadataError || + err instanceof VendorNameError + ) { + context.logger.warn(VendorUpdateValidationRejected, { + Reason: err.message, + UserId: context.principal.userId, + VendorId: vendorId + }); + } + return mapExpectedError(err); + } +} + +export async function handleEnrichVendor( + context: XpenserMcpToolContext, + input: VendorIdInput +): Promise { + const toolName = 'xpenser_enrich_vendor'; + logToolCall(context, toolName); + try { + return toolResult({ + vendor: await context.data.enrichVendor( + context.principal.userId, + input.id + ) + }); + } catch (err) { + return mapExpectedError(err); + } +} + export async function handleListTransactions( context: XpenserMcpToolContext, input: TransactionListInput @@ -383,6 +1097,63 @@ export async function handleListTransactions( }); } +export async function handleCreateTransaction( + context: XpenserMcpToolContext, + input: CreateTransactionInput +): Promise { + const toolName = 'xpenser_create_transaction'; + logToolCall(context, toolName); + try { + const transaction = await context.data.createTransaction( + context.principal.userId, + normalizeCreateTransactionInput(input) + ); + context.logger.info(TransactionCreated, { + TransactionId: transaction.id, + UserId: context.principal.userId + }); + return toolResult({ transaction }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleUpdateTransaction( + context: XpenserMcpToolContext, + input: UpdateTransactionInput +): Promise { + const toolName = 'xpenser_update_transaction'; + logToolCall(context, toolName); + try { + return toolResult({ + transaction: await context.data.updateTransaction( + context.principal.userId, + input.id, + normalizeUpdateTransactionInput(input) + ) + }); + } catch (err) { + return mapExpectedError(err); + } +} + +export async function handleDeleteTransaction( + context: XpenserMcpToolContext, + input: TransactionIdInput +): Promise { + const toolName = 'xpenser_delete_transaction'; + logToolCall(context, toolName); + try { + await context.data.deleteTransaction( + context.principal.userId, + input.id + ); + return toolResult({ deleted: true, id: input.id }); + } catch (err) { + return mapExpectedError(err); + } +} + export async function handleGetDashboardSummary( context: XpenserMcpToolContext, input: DashboardInput @@ -412,7 +1183,7 @@ export async function handleGetStatsOverview( }); } -function createXpenserMcpTools( +export function createXpenserMcpTools( context: XpenserMcpToolContext ): readonly XpenserMcpTool[] { return [ @@ -430,9 +1201,128 @@ function createXpenserMcpTools( title: 'List xpenser categories', description: 'Return income and expense categories for the authenticated xpenser user.', - inputSchema: EmptyInputSchema, + inputSchema: CategoryListInputSchema, + annotations: readOnlyAnnotations, + handler: input => + handleListCategories(context, input as CategoryListInput) + }, + { + name: 'xpenser_create_category', + title: 'Create xpenser category', + description: + 'Create an income or expense category for the authenticated xpenser user.', + inputSchema: CreateCategoryInputSchema, + annotations: additiveWriteAnnotations, + handler: input => + handleCreateCategory(context, input as CreateCategoryInput) + }, + { + name: 'xpenser_update_category', + title: 'Update xpenser category', + description: + 'Update a category name, hierarchy, reporting kind, type, or archive state for the authenticated xpenser user.', + inputSchema: UpdateCategoryInputSchema, + annotations: destructiveWriteAnnotations, + handler: input => + handleUpdateCategory(context, input as UpdateCategoryInput) + }, + { + name: 'xpenser_delete_category', + title: 'Delete xpenser category', + description: + 'Delete an unused leaf category owned by the authenticated xpenser user.', + inputSchema: CategoryIdInputSchema, + annotations: destructiveWriteAnnotations, + handler: input => + handleDeleteCategory(context, input as CategoryIdInput) + }, + { + name: 'xpenser_move_and_delete_category', + title: 'Move and delete xpenser category', + description: + 'Move transactions from a leaf category to a same-direction replacement category, then delete the source category.', + inputSchema: MoveAndDeleteCategoryInputSchema, + annotations: destructiveWriteAnnotations, + handler: input => + handleMoveAndDeleteCategory( + context, + input as MoveAndDeleteCategoryInput + ) + }, + { + name: 'xpenser_list_vendors', + title: 'List xpenser vendors', + description: + 'Return vendors owned by the authenticated xpenser user, with transaction counts and category suggestions.', + inputSchema: VendorListInputSchema, + annotations: readOnlyAnnotations, + handler: input => + handleListVendors(context, input as VendorListInput) + }, + { + name: 'xpenser_get_vendor', + title: 'Get xpenser vendor', + description: + 'Return one vendor owned by the authenticated xpenser user.', + inputSchema: VendorIdInputSchema, annotations: readOnlyAnnotations, - handler: () => handleListCategories(context) + handler: input => handleGetVendor(context, input as VendorIdInput) + }, + { + name: 'xpenser_search_vendor_candidates', + title: 'Search vendor candidates', + description: + 'Search Brandfetch for vendor candidates that can be used when creating a xpenser vendor.', + inputSchema: VendorCandidateSearchInputSchema, + annotations: openWorldReadOnlyAnnotations, + handler: input => + handleSearchVendorCandidates( + context, + input as VendorCandidateSearchInput + ) + }, + { + name: 'xpenser_get_vendor_candidate_details', + title: 'Get vendor candidate details', + description: + 'Fetch Brandfetch metadata for a selected vendor candidate by brand identifier or domain.', + inputSchema: VendorCandidateDetailsInputSchema, + annotations: openWorldReadOnlyAnnotations, + handler: input => + handleGetVendorCandidateDetails( + context, + input as VendorCandidateDetailsInput + ) + }, + { + name: 'xpenser_create_vendor', + title: 'Create xpenser vendor', + description: + 'Create or reuse a vendor owned by the authenticated xpenser user. Selected candidate metadata may be enriched through Brandfetch.', + inputSchema: CreateVendorInputSchema, + annotations: openWorldAdditiveWriteAnnotations, + handler: input => + handleCreateVendor(context, input as CreateVendorInput) + }, + { + name: 'xpenser_update_vendor', + title: 'Update xpenser vendor', + description: + 'Update editable metadata for a vendor owned by the authenticated xpenser user.', + inputSchema: UpdateVendorInputSchema, + annotations: destructiveWriteAnnotations, + handler: input => + handleUpdateVendor(context, input as UpdateVendorInput) + }, + { + name: 'xpenser_enrich_vendor', + title: 'Enrich xpenser vendor', + description: + 'Retry Brandfetch enrichment for a vendor owned by the authenticated xpenser user.', + inputSchema: VendorIdInputSchema, + annotations: openWorldDestructiveWriteAnnotations, + handler: input => + handleEnrichVendor(context, input as VendorIdInput) }, { name: 'xpenser_list_transactions', @@ -444,6 +1334,42 @@ function createXpenserMcpTools( handler: input => handleListTransactions(context, input as TransactionListInput) }, + { + name: 'xpenser_create_transaction', + title: 'Create xpenser transaction', + description: + 'Create a transaction and store its historical exchange rate for the authenticated xpenser user.', + inputSchema: CreateTransactionInputSchema, + annotations: openWorldAdditiveWriteAnnotations, + handler: input => + handleCreateTransaction( + context, + input as CreateTransactionInput + ) + }, + { + name: 'xpenser_update_transaction', + title: 'Update xpenser transaction', + description: + 'Update a transaction and recalculate converted values for the authenticated xpenser user.', + inputSchema: UpdateTransactionInputSchema, + annotations: openWorldDestructiveWriteAnnotations, + handler: input => + handleUpdateTransaction( + context, + input as UpdateTransactionInput + ) + }, + { + name: 'xpenser_delete_transaction', + title: 'Delete xpenser transaction', + description: + 'Delete a transaction owned by the authenticated xpenser user.', + inputSchema: TransactionIdInputSchema, + annotations: destructiveWriteAnnotations, + handler: input => + handleDeleteTransaction(context, input as TransactionIdInput) + }, { name: 'xpenser_get_dashboard_summary', title: 'Get xpenser dashboard summary', @@ -492,10 +1418,7 @@ export function registerXpenserMcpTools( server.setRequestHandler(CallToolRequestSchema, async request => { const tool = toolsByName.get(request.params.name); if (!tool) { - throw new McpError( - ErrorCode.InvalidParams, - `Tool ${request.params.name} not found.` - ); + throw invalidParams(`Tool ${request.params.name} not found.`); } const input = await validateToolInput( diff --git a/apps/web/components/landing-page.tsx b/apps/web/components/landing-page.tsx index aed4bd3..77b4805 100644 --- a/apps/web/components/landing-page.tsx +++ b/apps/web/components/landing-page.tsx @@ -118,13 +118,13 @@ const capabilityRows = [ 'Email summaries', 'Configurable weekly and monthly spending and income insights' ], - ['External API', 'Typed client, API keys, and read-only MCP server'] + ['External API', 'Typed client, API keys, and MCP server'] ] as const; const heroProofs = [ 'Self-hosted finance app', 'Multi-currency tracking', - 'Read-only MCP access', + 'MCP agent access', 'Telegram bot integration', 'Cleverbrush reference code', 'MIT licensed'