From e4de2af7feb1b7d27ed0de98074b19399340ea2c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:06:30 +0200 Subject: [PATCH] Add tool to update account balance to MCP server --- .../app/endpoints/mcp/mcp.controller.spec.ts | 12 ++- .../src/app/endpoints/mcp/mcp.controller.ts | 24 +++++- apps/api/src/app/endpoints/mcp/mcp.module.ts | 6 +- .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 38 ++++++++- apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 22 ++++- .../src/app/endpoints/mcp/mcp.service.spec.ts | 80 ++++++++++++++++++- apps/api/src/app/endpoints/mcp/mcp.service.ts | 42 +++++++++- 7 files changed, 216 insertions(+), 8 deletions(-) diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts index 78e84bf53d..fa250f455c 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -245,7 +245,8 @@ describe('GhostfolioMcpController', () => { 'get-portfolio', 'get-watchlist', 'import-activities', - 'search-asset-profiles' + 'search-asset-profiles', + 'update-account-balance' ]); }); @@ -287,7 +288,8 @@ describe('GhostfolioMcpController', () => { 'get-portfolio', 'get-watchlist', 'import-activities', - 'search-asset-profiles' + 'search-asset-profiles', + 'update-account-balance' ]); }); @@ -348,6 +350,12 @@ describe('GhostfolioMcpController', () => { ).toEqual([scopes.activityCreate]); }); + it('Requires the scope to update an account for the tool to update the account balance', () => { + expect( + getMetadataOfMethod(REQUIRES_SCOPE_KEY, 'updateAccountBalance') + ).toEqual([scopes.accountUpdate]); + }); + // The tools have no try and catch, hence the filter is the only guarantee // that an unexpected exception does not expose internals it('Applies the filter of the exceptions of the tools', () => { diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 66e1ff7098..1efac05e83 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -18,7 +18,8 @@ import { GET_ACTIVITIES_PARAMETERS, GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, - SEARCH_ASSET_PROFILES_PARAMETERS + SEARCH_ASSET_PROFILES_PARAMETERS, + UPDATE_ACCOUNT_BALANCE_PARAMETERS } from './mcp.schemas'; import { McpService } from './mcp.service'; @@ -193,6 +194,27 @@ export class GhostfolioMcpController { return this.mcpService.searchAssetProfiles({ ...parameters, userId }); } + @RequiresScopeOfAccess(scopes.accountUpdate) + @Tool({ + annotations: { + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: false, + title: 'Update account balance' + }, + description: + 'Sets the cash balance of an account at a date. The balance is in the currency of the account, which get-accounts gives. A cash balance of the account at the same date is replaced. The cash balance at the most recent date is the current cash balance of the account. A date in the future is refused.', + name: 'update-account-balance', + parameters: UPDATE_ACCOUNT_BALANCE_PARAMETERS + }) + public async updateAccountBalance( + @Impersonation() { userId }: ImpersonationContext, + @Payload() parameters: z.infer + ) { + return this.mcpService.updateAccountBalance({ ...parameters, userId }); + } + private hasScopeToReadValues(impersonationScopes: string[]) { return hasScope(impersonationScopes, scopes.portfolioReadValues); } diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index 26704c6924..c1e2885b7f 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,3 +1,5 @@ +import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; +import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { ImportModule } from '@ghostfolio/api/app/import/import.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; @@ -24,6 +26,8 @@ import { McpService } from './mcp.service'; controllers: [GhostfolioMcpController], exports: [McpService], imports: [ + AccountBalanceModule, + AccountModule, ApiModule, ConfigurationModule, ImportModule, @@ -40,7 +44,7 @@ import { McpService } from './mcp.service'; const { hostname } = new URL(configurationService.get('ROOT_URL')); const instructions = [ - 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give the quantities and the monetary values only if the access has the permission "View" or "View and manage", otherwise no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage" or "View and manage".' + 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access, import activities into the portfolio and update the cash balances of the accounts. They give the quantities and the monetary values only if the access has the permission "View" or "View and manage", otherwise no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities or to update a cash balance, the access needs the permission "Restricted view and manage" or "View and manage".' ]; if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index e4893c2a7b..9969aa7fab 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -7,15 +7,18 @@ import { SEARCH_QUERY_MINIMUM_LENGTH, SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataSource } from '@prisma/client'; +import { addDays, format } from 'date-fns'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, - SEARCH_ASSET_PROFILES_PARAMETERS + SEARCH_ASSET_PROFILES_PARAMETERS, + UPDATE_ACCOUNT_BALANCE_PARAMETERS } from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; @@ -312,3 +315,36 @@ describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => { ).toBe('Apple'); }); }); + +describe('UPDATE_ACCOUNT_BALANCE_PARAMETERS', () => { + function parse(parameters: Record) { + return UPDATE_ACCOUNT_BALANCE_PARAMETERS.safeParse({ + accountId: 'account-id', + balance: 1000, + date: '2024-01-01', + ...parameters + }).success; + } + + it('Accepts a cash balance of today', () => { + expect(parse({ date: format(new Date(), DATE_FORMAT) })).toBe(true); + }); + + it('Accepts a negative cash balance', () => { + expect(parse({ balance: -1000 })).toBe(true); + }); + + it('Refuses an empty identifier of the account', () => { + expect(parse({ accountId: '' })).toBe(false); + }); + + it('Refuses a date at or before the epoch', () => { + expect(parse({ date: '0000-01-01' })).toBe(false); + }); + + it('Refuses a date in the future', () => { + expect(parse({ date: format(addDays(new Date(), 2), DATE_FORMAT) })).toBe( + false + ); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index 1b200e0119..e4028ddda0 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -11,11 +11,14 @@ import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; import { + isAccountBalanceInFuture, isValidCurrencyCode, - isValidDateAfter1970 + isValidDateAfter1970, + resetHours } from '@ghostfolio/common/helper'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; +import { parseISO } from 'date-fns'; import { z } from 'zod'; const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`; @@ -179,3 +182,20 @@ export const SEARCH_ASSET_PROFILES_PARAMETERS = z.object({ 'The name, ticker symbol or ISIN of the financial asset, for example Apple, AAPL, Bitcoin or US0378331005' ) }); + +export const UPDATE_ACCOUNT_BALANCE_PARAMETERS = z.object({ + accountId: z.string().min(1).describe('The identifier of the account'), + balance: z + .number() + .describe('The cash balance of the account in the currency of the account'), + date: z + .string() + .refine(isValidDateAfter1970) + .refine( + (date) => { + return !isAccountBalanceInFuture({ date: resetHours(parseISO(date)) }); + }, + { error: 'The date must not be in the future' } + ) + .describe('The date of the cash balance, as an ISO 8601 date') +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index d6a3211144..4f622d20e6 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -1,7 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -12,7 +15,10 @@ import { } from '@ghostfolio/common/config'; import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { UserWithSettings } from '@ghostfolio/common/types'; +import type { + AccountWithBalance, + UserWithSettings +} from '@ghostfolio/common/types'; import { HttpException } from '@nestjs/common'; import { @@ -30,6 +36,8 @@ describe('McpService', () => { const userCurrency = 'USD'; const userId = 'user-id'; + let accountBalanceService: AccountBalanceService; + let accountService: AccountService; let apiService: ApiService; let configuration: Record; let configurationService: ConfigurationService; @@ -62,6 +70,12 @@ describe('McpService', () => { }) } as unknown as ConfigurationService; + accountBalanceService = { + createOrUpdateAccountBalance: jest.fn() + } as unknown as AccountBalanceService; + + accountService = { account: jest.fn() } as unknown as AccountService; + apiService = { buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters) } as unknown as ApiService; @@ -85,6 +99,8 @@ describe('McpService', () => { userService = { user: jest.fn() } as unknown as UserService; mcpService = new McpService( + accountBalanceService, + accountService, apiService, configurationService, importService, @@ -636,4 +652,66 @@ describe('McpService', () => { ).rejects.toBe(error); }); }); + + describe('updateAccountBalance', () => { + const parameters = { + userId, + accountId: 'account-id', + balance: 1000, + date: '2024-01-01' + }; + + it('Refuses a user without the permission to create an account balance', async () => { + setupUser([]); + + await expect(mcpService.updateAccountBalance(parameters)).rejects.toThrow( + HttpException + ); + + expect( + accountBalanceService.createOrUpdateAccountBalance + ).not.toHaveBeenCalled(); + }); + + it('Refuses an account which does not belong to the user', async () => { + setupUser([permissions.createAccountBalance]); + + jest.spyOn(accountService, 'account').mockResolvedValue(null); + + await expect(mcpService.updateAccountBalance(parameters)).rejects.toThrow( + CallerFacingError + ); + + expect(accountService.account).toHaveBeenCalledWith({ + id_userId: { userId, id: 'account-id' } + }); + + expect( + accountBalanceService.createOrUpdateAccountBalance + ).not.toHaveBeenCalled(); + }); + + it('Updates the cash balance of the account of the user', async () => { + setupUser([permissions.createAccountBalance]); + + jest + .spyOn(accountService, 'account') + .mockResolvedValue({ userId, id: 'account-id' } as AccountWithBalance); + + expect(await mcpService.updateAccountBalance(parameters)).toEqual({ + content: [ + { text: 'Updated the cash balance of the account', type: 'text' } + ] + }); + + expect( + accountBalanceService.createOrUpdateAccountBalance + ).toHaveBeenCalledWith({ + userId, + accountId: 'account-id', + balance: 1000, + date: '2024-01-01' + }); + }); + }); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 4b4c32f6c5..3f75b62372 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -1,6 +1,9 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; import { transformDataSourceInRequest, transformDataSourceInResponse @@ -21,12 +24,15 @@ import { GET_ACTIVITIES_PARAMETERS, GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, - SEARCH_ASSET_PROFILES_PARAMETERS + SEARCH_ASSET_PROFILES_PARAMETERS, + UPDATE_ACCOUNT_BALANCE_PARAMETERS } from './mcp.schemas'; @Injectable() export class McpService { public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly accountService: AccountService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly importService: ImportService, @@ -243,6 +249,40 @@ export class McpService { return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); } + public async updateAccountBalance({ + accountId, + balance, + date, + userId + }: z.infer & { userId: string }) { + await this.getUserWithPermission({ + userId, + permission: permissions.createAccountBalance + }); + + const account = await this.accountService.account({ + id_userId: { + userId, + id: accountId + } + }); + + if (!account) { + throw new CallerFacingError( + 'The account does not exist. Use get-accounts to get the identifier of the account.' + ); + } + + await this.accountBalanceService.createOrUpdateAccountBalance({ + balance, + date, + accountId: account.id, + userId: account.userId + }); + + return this.getTextResult('Updated the cash balance of the account'); + } + private getHoldingFilterParameters({ holding }: Pick, 'holding'>) {