From fa4b4cc8bae95e0760f9d78a4cdc6aa4ad12ed6a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:43:13 +0200 Subject: [PATCH] Feature/add MCP tool to get watchlist (#7920) * Add MCP tool to get watchlist * Update changelog --- CHANGELOG.md | 4 + .../src/app/endpoints/mcp/mcp.controller.ts | 16 +++ apps/api/src/app/endpoints/mcp/mcp.module.ts | 2 +- .../src/app/endpoints/mcp/mcp.service.spec.ts | 15 ++- apps/api/src/app/endpoints/mcp/mcp.service.ts | 8 ++ .../endpoints/watchlist/watchlist.module.ts | 1 + .../portfolio-table/portfolio-table.module.ts | 3 +- .../portfolio-table.service.spec.ts | 87 ++++++++++++++- .../portfolio-table.service.ts | 102 ++++++++++++++++-- 9 files changed, 222 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b00ee0b3c..2f373433b9 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 watchlist to the server of the Model Context Protocol (MCP) (experimental) + ### Changed - Extended the cache key of the portfolio snapshot by the performance calculation type diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 3fe90c4dc3..dee145015f 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -85,6 +85,22 @@ export class GhostfolioMcpController { return this.mcpService.getPortfolio({ userId }); } + @RequiresScopeOfAccess(scopes.watchlistRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get watchlist' + }, + description: `Gives the watchlist of the user, sorted by name, with these columns: ${PortfolioTableService.getWatchlistTableColumnNames().join( + ', ' + )}. A trend compares the average market price of the last 50 or 200 days with the average of the 50 or 200 days before. A trend is UNKNOWN if there is not sufficient market data. The change from the all time high is the difference between the current market price and the all time high in percentage.`, + name: 'get-watchlist' + }) + public async getWatchlist(@Impersonation() { userId }: ImpersonationContext) { + return this.mcpService.getWatchlist({ userId }); + } + /** * The transport gives the tool to every client, because it filters the list * of the tools by the scopes of request.user, which a request of an access diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index 33cf667118..be81137f54 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -38,7 +38,7 @@ import { McpService } from './mcp.service'; return new McpStrategy({ instructions: - 'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access and import activities into it. They give no quantity and no monetary value (except the unit price of an activity).', + '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 no quantity and no monetary value (except the unit price of an activity).', name: 'ghostfolio', title: 'Ghostfolio', transports: [ 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 2ec4947c15..a29daaba93 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -72,7 +72,8 @@ describe('McpService', () => { portfolioTableService = { getAccountsTable: jest.fn().mockResolvedValue('## Accounts'), getActivitiesTable: jest.fn().mockResolvedValue('## Activities'), - getHoldingsTable: jest.fn().mockResolvedValue('## Holdings') + getHoldingsTable: jest.fn().mockResolvedValue('## Holdings'), + getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist') } as unknown as PortfolioTableService; symbolService = { @@ -199,6 +200,18 @@ describe('McpService', () => { }); }); + describe('getWatchlist', () => { + it('Gives the table of the watchlist', async () => { + expect(await mcpService.getWatchlist({ userId })).toEqual({ + content: [{ text: '## Watchlist', type: 'text' }] + }); + + expect(portfolioTableService.getWatchlistTable).toHaveBeenCalledWith({ + userId + }); + }); + }); + describe('searchAssetProfiles', () => { it('Refuses a user without the permission to create an activity', async () => { setupUser([]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 1bf25cdcce..474c62e69c 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -106,6 +106,14 @@ export class McpService { return this.getTextResult(table); } + public async getWatchlist({ userId }: { userId: string }) { + const table = await this.portfolioTableService.getWatchlistTable({ + userId + }); + + return this.getTextResult(table); + } + public async importActivities({ activities, userId diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts index 9b4b960a08..80556d127f 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts @@ -15,6 +15,7 @@ import { WatchlistService } from './watchlist.service'; @Module({ controllers: [WatchlistController], + exports: [WatchlistService], imports: [ BenchmarkModule, DataGatheringQueueModule, diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts index 4411bf9c8a..0995dc9980 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.module.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -1,4 +1,5 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; +import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; @@ -8,7 +9,7 @@ import { PortfolioTableService } from './portfolio-table.service'; @Module({ exports: [PortfolioTableService], - imports: [ActivitiesModule, I18nModule, PortfolioModule], + imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], providers: [PortfolioTableService] }) export class PortfolioTableModule {} 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 2d9287051a..ba2be47cf6 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 @@ -1,3 +1,4 @@ +import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; @@ -5,10 +6,13 @@ import { DEFAULT_LANGUAGE_CODE, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; -import { PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { + PortfolioPosition, + WatchlistResponse +} from '@ghostfolio/common/interfaces'; import { AccountWithValue } from '@ghostfolio/common/types'; -import { AssetClass, AssetSubClass } from '@prisma/client'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { PortfolioTableService } from './portfolio-table.service'; @@ -105,12 +109,39 @@ function createHolding({ } as unknown as PortfolioPosition; } +function createWatchlistItem({ + name = 'Name of AAPL', + performancePercent = -0.25, + symbol = 'AAPL' +}: { + name?: string; + performancePercent?: number; + symbol?: string; +} = {}): WatchlistResponse['watchlist'][number] { + return { + name, + symbol, + dataSource: DataSource.YAHOO, + marketCondition: 'BEAR_MARKET', + performances: { + allTimeHigh: { + performancePercent, + date: new Date('2024-01-01T00:00:00') + } + }, + trend50d: 'UP', + trend200d: 'DOWN' + }; +} + function createPortfolioTableService({ accounts = [], - holdings = [] + holdings = [], + watchlist = [] }: { accounts?: AccountWithValue[]; holdings?: PortfolioPosition[]; + watchlist?: WatchlistResponse['watchlist']; } = {}) { // The mock gives the identifier of the translation, so that a test can tell // the translation of the asset class from that of the asset sub class @@ -125,7 +156,16 @@ function createPortfolioTableService({ getDetails: jest.fn().mockResolvedValue({ holdings }) } as unknown as PortfolioService; - return new PortfolioTableService(null, i18nService, portfolioService); + const watchlistService = { + getWatchlistItems: jest.fn().mockResolvedValue(watchlist) + } as unknown as WatchlistService; + + return new PortfolioTableService( + null, + i18nService, + portfolioService, + watchlistService + ); } describe('PortfolioTableService', () => { @@ -175,6 +215,20 @@ describe('PortfolioTableService', () => { }); }); + describe('getWatchlistTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ + 'Name', + 'Symbol', + 'Trend 50 Days', + 'Trend 200 Days', + 'Date of Last All Time High', + 'Change from All Time High', + 'Market Condition' + ]); + }); + }); + describe('getAccountsTable', () => { it('gives no cash balance and no value of an account', async () => { const portfolioTableService = createPortfolioTableService({ @@ -269,4 +323,29 @@ describe('PortfolioTableService', () => { expect(secondRow).toContain('MSFT'); }); }); + + describe('getWatchlistTable', () => { + function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { + return createPortfolioTableService({ watchlist }).getWatchlistTable({ + userId: 'user-id' + }); + } + + it('gives the date and the change of the all time high', async () => { + const result = await getWatchlistTable([createWatchlistItem()]); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain('2024-01-01'); + expect(row).toContain('-25.000%'); + }); + + it('tells that no watchlist items are found if the result is empty', async () => { + const result = await getWatchlistTable([]); + + expect(result).toContain('No watchlist items found.'); + }); + }); }); 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 f8cc3cfc67..9f53bef065 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,10 +1,15 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; -import { Activity, Filter } from '@ghostfolio/common/interfaces'; +import { + Activity, + Filter, + WatchlistResponse +} from '@ghostfolio/common/interfaces'; import { AccountWithValue } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -17,14 +22,14 @@ import { format } from 'date-fns'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; -function getAllocationInPercentage(allocationInPercentage: number) { - return `${(allocationInPercentage * 100).toFixed(3)}%`; +function getPercentage(value: number) { + return `${(value * 100).toFixed(3)}%`; } /** - * Renders the accounts, the activities and the holdings of a portfolio 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 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. */ @Injectable() export class PortfolioTableService { @@ -64,7 +69,7 @@ export class PortfolioTableService { { align: 'right', getValue: ({ allocationInPercentage }) => { - return getAllocationInPercentage(allocationInPercentage); + return getPercentage(allocationInPercentage); }, name: 'Allocation in Percentage' }, @@ -173,16 +178,67 @@ export class PortfolioTableService { { align: 'right', getValue: ({ allocationInPercentage }) => { - return getAllocationInPercentage(allocationInPercentage); + return getPercentage(allocationInPercentage); }, name: 'Allocation in Percentage' } ]; + private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< + WatchlistResponse['watchlist'][number] + >[] = [ + { + getValue: ({ name }) => { + return name ?? ''; + }, + name: 'Name' + }, + { + getValue: ({ symbol }) => { + return symbol; + }, + name: 'Symbol' + }, + { + getValue: ({ trend50d }) => { + return trend50d; + }, + name: 'Trend 50 Days' + }, + { + getValue: ({ trend200d }) => { + return trend200d; + }, + name: 'Trend 200 Days' + }, + { + getValue: ({ performances }) => { + return performances.allTimeHigh.date + ? format(performances.allTimeHigh.date, DATE_FORMAT) + : ''; + }, + name: 'Date of Last All Time High' + }, + { + align: 'right', + getValue: ({ performances }) => { + return getPercentage(performances.allTimeHigh.performancePercent); + }, + name: 'Change from All Time High' + }, + { + getValue: ({ marketCondition }) => { + return marketCondition; + }, + name: 'Market Condition' + } + ]; + public constructor( private readonly activitiesService: ActivitiesService, private readonly i18nService: I18nService, - private readonly portfolioService: PortfolioService + private readonly portfolioService: PortfolioService, + private readonly watchlistService: WatchlistService ) {} public static getAccountsTableColumnNames() { @@ -209,6 +265,14 @@ export class PortfolioTableService { ); } + public static getWatchlistTableColumnNames() { + return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( + ({ name }) => { + return name; + } + ); + } + public async getAccountsTable({ filters, userId @@ -340,6 +404,26 @@ export class PortfolioTableService { ].join('\n'); } + public async getWatchlistTable({ userId }: { userId: string }) { + const watchlist = await this.watchlistService.getWatchlistItems(userId); + + const watchlistSection = ['## Watchlist', '']; + + if (watchlist.length > 0) { + watchlistSection.push( + await getMarkdownTable({ + columnDefinitions: + PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, + rows: watchlist + }) + ); + } else { + watchlistSection.push('No watchlist items found.'); + } + + return watchlistSection.join('\n'); + } + private getActivitiesSummary({ count, numberOfActivities,