diff --git a/CHANGELOG.md b/CHANGELOG.md index 596a34e60c..808f250ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a tool to update the cash balance of an account to the server of the Model Context Protocol (MCP) (experimental) + ### Changed - Improved the colors of the muted texts and borders by enabling the dark mode of `bootstrap` diff --git a/README.md b/README.md index 88f6108340..5cb3c63857 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_. ## Model Context Protocol (experimental) -The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio and import activities into it. +The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio, import activities into it and update the cash balances of your accounts. ### Prerequisites @@ -407,7 +407,7 @@ The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio - Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin. - Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token -An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities. +An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities and update the cash balances of your accounts. ### Connect a client 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..facfe3fd75 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -15,7 +15,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 { createActivity } from './mcp.test-utils'; @@ -312,3 +313,47 @@ describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => { ).toBe('Apple'); }); }); + +describe('UPDATE_ACCOUNT_BALANCE_PARAMETERS', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-03T23:30:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + 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: '2026-09-03' })).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: '1969-12-31' })).toBe(false); + expect(parse({ date: '1970-01-01' })).toBe(false); + }); + + it('Refuses a date and time', () => { + expect(parse({ date: '2024-01-01T00:30:00+02:00' })).toBe(false); + }); + + it('Refuses a date in the future', () => { + expect(parse({ date: '2026-09-05' })).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..1802305ae6 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -11,6 +11,7 @@ import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; import { + isValidAccountBalanceDate, isValidCurrencyCode, isValidDateAfter1970 } from '@ghostfolio/common/helper'; @@ -179,3 +180,22 @@ 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.iso + .date({ + abort: true, + error: 'The date must be an ISO 8601 date, for example 2024-01-01' + }) + .refine(isValidDateAfter1970, { + error: 'The date must be after 1970-01-01' + }) + .refine(isValidAccountBalanceDate, { + 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..841c29ea49 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,69 @@ 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 at 2024-01-01', + 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..c3aa289dd5 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,42 @@ 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 at ${date}` + ); + } + private getHoldingFilterParameters({ holding }: Pick, 'holding'>) { diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index 97d6ef1f8f..a49a061e53 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -258,9 +258,9 @@ describe('Scopes', () => { ).not.toContain(scopes.portfolioReadValues); }); - // The controller exposes the tool to import activities as the only tool - // which writes, hence a further write scope stays ineffective - it('Gives the write scope to create an activity only', () => { + // The controller exposes no tool for a further write scope, hence it stays + // ineffective + it('Gives the write scopes to update an account and to create an activity only', () => { expect( getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS], @@ -268,7 +268,7 @@ describe('Scopes', () => { }).filter((scope) => { return SCOPES_OF_WRITE_ACCESS.includes(scope as Scope); }) - ).toEqual([scopes.activityCreate]); + ).toEqual([scopes.accountUpdate, scopes.activityCreate]); }); // The dialog narrows the access level to the scopes which the type diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index 39b1954fc2..de0b17cff5 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -68,7 +68,7 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = * monetary values. */ const SCOPES_OF_TYPE: Record = { - MCP: [...SCOPES_OF_READ_ACCESS, scopes.activityCreate], + MCP: [...SCOPES_OF_READ_ACCESS, scopes.accountUpdate, scopes.activityCreate], PRIVATE: Object.values(scopes), PUBLIC: SCOPES_OF_PUBLIC_ACCESS }; diff --git a/prisma/migrations/20261014000000_added_account_update_scope_to_mcp_access/migration.sql b/prisma/migrations/20261014000000_added_account_update_scope_to_mcp_access/migration.sql new file mode 100644 index 0000000000..fba4918516 --- /dev/null +++ b/prisma/migrations/20261014000000_added_account_update_scope_to_mcp_access/migration.sql @@ -0,0 +1,6 @@ +-- Grant the scope to update an account to the existing accesses of the model context protocol which can create an activity +UPDATE "Access" +SET "scopes" = array_append("scopes", 'account:update') +WHERE "type" = 'MCP' + AND 'activity:create' = ANY("scopes") + AND NOT ('account:update' = ANY("scopes"));