From f62cce7e8df7df90eb35b12d453646c85d98b0f6 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:42:22 +0200 Subject: [PATCH] Feature/add MCP tool to get performance (#7994) * Add MCP tool to get performance * Update changelog --- CHANGELOG.md | 4 + .../app/endpoints/mcp/mcp.controller.spec.ts | 2 + .../src/app/endpoints/mcp/mcp.controller.ts | 21 +++++ .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 20 ++++ apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 29 ++++++ .../src/app/endpoints/mcp/mcp.service.spec.ts | 32 +++++++ apps/api/src/app/endpoints/mcp/mcp.service.ts | 24 +++++ .../portfolio-table.service.spec.ts | 94 ++++++++++++++++++- .../portfolio-table.service.ts | 85 ++++++++++++++++- 9 files changed, 306 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e81086871d..d91e5a95f6 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 get the performance to the server of the Model Context Protocol (MCP) (experimental) + ### Changed - Upgraded `prettier` from version `3.9.6` to `3.9.9` 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 4fcb92a9be..e3c60d2829 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -133,6 +133,7 @@ describe('GhostfolioMcpController', () => { ).toEqual([ 'get-accounts', 'get-activities', + 'get-performance', 'get-portfolio', 'get-watchlist' ]); @@ -154,6 +155,7 @@ describe('GhostfolioMcpController', () => { ).toEqual([ 'get-accounts', 'get-activities', + 'get-performance', 'get-portfolio', 'get-watchlist', 'import-activities', diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 802567ad35..742080498b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -15,6 +15,7 @@ import 'zod/compile'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, + GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; @@ -69,6 +70,26 @@ export class GhostfolioMcpController { }); } + @RequiresScopeOfAccess(scopes.portfolioRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get performance' + }, + description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join( + ', ' + )}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and excludes the dividends. The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, + name: 'get-performance', + parameters: GET_PERFORMANCE_PARAMETERS + }) + public async getPerformance( + @Impersonation() { userId }: ImpersonationContext, + @Payload() parameters: z.infer + ) { + return this.mcpService.getPerformance({ ...parameters, userId }); + } + @RequiresScopeOfAccess(scopes.portfolioRead) @Tool({ annotations: { 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 6fb9086d49..2480b6726d 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,4 +1,5 @@ import { + DEFAULT_DATE_RANGE, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH @@ -9,6 +10,7 @@ import { DataSource } from '@prisma/client'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, + GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; @@ -50,6 +52,24 @@ describe('GET_ACTIVITIES_PARAMETERS', () => { }); }); +describe('GET_PERFORMANCE_PARAMETERS', () => { + it(`Gives the date range ${DEFAULT_DATE_RANGE} if the range is absent`, () => { + expect(GET_PERFORMANCE_PARAMETERS.parse({}).range).toBe(DEFAULT_DATE_RANGE); + }); + + it('Accepts a calendar year', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ range: '2024' }).success + ).toBe(true); + }); + + it('Refuses an unknown date range', () => { + expect(GET_PERFORMANCE_PARAMETERS.safeParse({ range: '2w' }).success).toBe( + false + ); + }); +}); + describe('IMPORT_ACTIVITIES_PARAMETERS', () => { function parse(activities: unknown[]) { return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index 52c95471f2..4bcc385b35 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -1,6 +1,7 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DATE_RANGES, + DEFAULT_DATE_RANGE, MCP_MAX_ACCOUNTS, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, @@ -84,6 +85,34 @@ export const GET_ACTIVITIES_PARAMETERS = z.object({ .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) }); +export const GET_PERFORMANCE_PARAMETERS = z.object({ + accountIds: z + .array(z.string().min(1)) + .min(1) + .max(MCP_MAX_ACCOUNTS) + .optional() + .describe( + `The identifiers of the accounts of the performance, at most ${MCP_MAX_ACCOUNTS}` + ), + assetClasses: z + .array(z.enum(AssetClass)) + .min(1) + .optional() + .describe('The asset classes of the performance'), + holding: HOLDING_PARAMETER.optional().describe( + 'The asset profile of the performance' + ), + range: z + .string() + .regex(DATE_RANGE_PATTERN) + .default(DEFAULT_DATE_RANGE) + .describe( + `The date range of the performance, either ${DATE_RANGES.join( + ', ' + )} or a calendar year like 2024` + ) +}); + export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ activities: z .array( 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 6167d0421a..d4e37c695e 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -73,6 +73,7 @@ describe('McpService', () => { getAccountsTable: jest.fn().mockResolvedValue('## Accounts'), getActivitiesTable: jest.fn().mockResolvedValue('## Activities'), getHoldingsTable: jest.fn().mockResolvedValue('## Holdings'), + getPerformanceTable: jest.fn().mockResolvedValue('## Performance'), getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist') } as unknown as PortfolioTableService; @@ -241,6 +242,37 @@ describe('McpService', () => { }); }); + describe('getPerformance', () => { + it('Maps the parameters of the tool to the filters', async () => { + await mcpService.getPerformance({ + userId, + accountIds: ['account-id'], + assetClasses: [AssetClass.EQUITY], + holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({ + filterByAccounts: ['account-id'], + filterByAssetClasses: [AssetClass.EQUITY], + filterByDataSource: DataSource.YAHOO, + filterBySymbol: 'AAPL' + }); + }); + + it('Gives the table of the performance of the filters in the range', async () => { + expect( + await mcpService.getPerformance({ userId, range: '2024' }) + ).toEqual({ content: [{ text: '## Performance', type: 'text' }] }); + + expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith({ + filters, + userId, + dateRange: '2024' + }); + }); + }); + describe('getPortfolio', () => { it('Gives the table of the holdings in the default language', async () => { expect(await mcpService.getPortfolio({ userId })).toEqual({ diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index cd0edb548d..d9d218267a 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, + GET_PERFORMANCE_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; @@ -95,6 +96,29 @@ export class McpService { return this.getTextResult(table); } + public async getPerformance({ + accountIds, + assetClasses, + holding, + range, + userId + }: z.infer & { userId: string }) { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts: accountIds, + filterByAssetClasses: assetClasses, + filterByDataSource: holding?.dataSource, + filterBySymbol: holding?.symbol + }); + + const table = await this.portfolioTableService.getPerformanceTable({ + filters, + userId, + dateRange: range + }); + + return this.getTextResult(table); + } + public async getPortfolio({ userId }: { userId: string }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index ba2be47cf6..ce92747c40 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -7,6 +7,8 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + HistoricalDataItem, + PortfolioPerformanceResponse, PortfolioPosition, WatchlistResponse } from '@ghostfolio/common/interfaces'; @@ -109,6 +111,26 @@ function createHolding({ } as unknown as PortfolioPosition; } +function createPerformance({ + netPerformancePercentage = 0.1, + netPerformancePercentageWithCurrencyEffect = 0.15 +}: { + netPerformancePercentage?: number; + netPerformancePercentageWithCurrencyEffect?: number; +} = {}): PortfolioPerformanceResponse['performance'] { + return { + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + currentNetWorth: 3000, + currentValueInBaseCurrency: 2000, + dividendInBaseCurrency: 50, + netPerformance: 200, + netPerformanceWithCurrencyEffect: 300, + totalInvestment: 1700, + totalInvestmentValueWithCurrencyEffect: 1700 + }; +} + function createWatchlistItem({ name = 'Name of AAPL', performancePercent = -0.25, @@ -136,11 +158,15 @@ function createWatchlistItem({ function createPortfolioTableService({ accounts = [], + chart = [{ date: '2024-01-01' }], holdings = [], + performance = createPerformance(), watchlist = [] }: { accounts?: AccountWithValue[]; + chart?: HistoricalDataItem[]; holdings?: PortfolioPosition[]; + performance?: PortfolioPerformanceResponse['performance']; watchlist?: WatchlistResponse['watchlist']; } = {}) { // The mock gives the identifier of the translation, so that a test can tell @@ -153,7 +179,8 @@ function createPortfolioTableService({ const portfolioService = { getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), - getDetails: jest.fn().mockResolvedValue({ holdings }) + getDetails: jest.fn().mockResolvedValue({ holdings }), + getPerformance: jest.fn().mockResolvedValue({ chart, performance }) } as unknown as PortfolioService; const watchlistService = { @@ -215,6 +242,16 @@ describe('PortfolioTableService', () => { }); }); + describe('getPerformanceTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([ + 'Asset Performance in Percentage', + 'Currency Performance in Percentage', + 'Net Performance in Percentage' + ]); + }); + }); + describe('getWatchlistTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ @@ -324,6 +361,61 @@ describe('PortfolioTableService', () => { }); }); + describe('getPerformanceTable', () => { + function getPerformanceTable( + parameters: Parameters[0] = {} + ) { + return createPortfolioTableService(parameters).getPerformanceTable({ + dateRange: 'ytd', + userId: 'user-id' + }); + } + + it('gives the currency performance as the difference of the net performance and the asset performance', async () => { + const result = await getPerformanceTable(); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toBe('| 10.000% | 5.000% | 15.000% |'); + }); + + it('gives a currency performance of zero without a sign', async () => { + const result = await getPerformanceTable({ + performance: createPerformance({ + netPerformancePercentage: 0.10000000000000003, + netPerformancePercentageWithCurrencyEffect: 0.1 + }) + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| 10.000%'); + }); + + expect(row).toBe('| 10.000% | 0.000% | 10.000% |'); + }); + + it('gives no monetary value', async () => { + const result = await getPerformanceTable(); + + const [, , row] = result.split('\n').filter((line) => { + return line.startsWith('|'); + }); + + for (const cell of row.split('|').slice(1, -1)) { + expect(cell.trim()).toMatch(/^-?\d+\.\d{3}%$/); + } + }); + + it('tells that no performance is found if the chart is empty', async () => { + const result = await getPerformanceTable({ chart: [] }); + + expect(result).toContain('No performance found.'); + expect(result).not.toContain('%'); + }); + }); + describe('getWatchlistTable', () => { function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { return createPortfolioTableService({ watchlist }).getWatchlistTable({ diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index 9f53bef065..a9c4c36121 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -8,9 +8,10 @@ import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { Activity, Filter, + PortfolioPerformance, WatchlistResponse } from '@ghostfolio/common/interfaces'; -import { AccountWithValue } from '@ghostfolio/common/types'; +import { AccountWithValue, DateRange } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { @@ -27,9 +28,10 @@ function getPercentage(value: number) { } /** - * Renders the accounts, the activities and the holdings of a portfolio and the - * watchlist of its user as a markdown table. No table has a column with a - * quantity or with a monetary value, except the unit price of an activity. + * Renders the accounts, the activities, the holdings and the performance of a + * portfolio and the watchlist of its user as a markdown table. No table has a + * column with a quantity or with a monetary value, except the unit price of an + * activity. */ @Injectable() export class PortfolioTableService { @@ -184,6 +186,41 @@ export class PortfolioTableService { } ]; + private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + align: 'right', + getValue: ({ netPerformancePercentage }) => { + return getPercentage(netPerformancePercentage); + }, + name: 'Asset Performance in Percentage' + }, + { + align: 'right', + getValue: ({ + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect + }) => { + const currencyPerformancePercentage = getPercentage( + netPerformancePercentageWithCurrencyEffect - + netPerformancePercentage + ); + + return Number.parseFloat(currencyPerformancePercentage) === 0 + ? getPercentage(0) + : currencyPerformancePercentage; + }, + name: 'Currency Performance in Percentage' + }, + { + align: 'right', + getValue: ({ netPerformancePercentageWithCurrencyEffect }) => { + return getPercentage(netPerformancePercentageWithCurrencyEffect); + }, + name: 'Net Performance in Percentage' + } + ]; + private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< WatchlistResponse['watchlist'][number] >[] = [ @@ -265,6 +302,14 @@ export class PortfolioTableService { ); } + public static getPerformanceTableColumnNames() { + return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + public static getWatchlistTableColumnNames() { return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( ({ name }) => { @@ -404,6 +449,38 @@ export class PortfolioTableService { ].join('\n'); } + public async getPerformanceTable({ + dateRange, + filters, + userId + }: { + dateRange: DateRange; + filters?: Filter[]; + userId: string; + }) { + const { chart, performance } = await this.portfolioService.getPerformance({ + dateRange, + filters, + userId + }); + + const performanceSection = ['## Performance', '']; + + if (chart?.length > 0) { + performanceSection.push( + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, + rows: [performance] + }) + ); + } else { + performanceSection.push('No performance found.'); + } + + return performanceSection.join('\n'); + } + public async getWatchlistTable({ userId }: { userId: string }) { const watchlist = await this.watchlistService.getWatchlistItems(userId);