From 7b5c738dff15950f4c20c35055ffd8e0e6c9df44 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:41:31 +0200 Subject: [PATCH] Extend tools with data source --- .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 15 ++++ apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 12 +++- .../src/app/endpoints/mcp/mcp.service.spec.ts | 7 +- apps/api/src/app/endpoints/mcp/mcp.service.ts | 3 +- apps/api/src/helper/data-source.helper.ts | 2 +- .../portfolio-table.service.spec.ts | 47 ++++++++++-- .../portfolio-table.service.ts | 72 +++++++++++-------- 7 files changed, 116 insertions(+), 42 deletions(-) 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 25a8207fc3..e4893c2a7b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -56,6 +56,21 @@ describe('GET_ACCOUNTS_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Gives the valid data sources in the error of an unknown data source of the holding', () => { + for (const dataSource of ['yahoo', 1]) { + const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource, symbol: 'AAPL' } + }); + + expect(error.issues).toEqual([ + expect.objectContaining({ + message: expect.stringContaining(`|${DataSource.YAHOO}`), + path: ['holding', 'dataSource'] + }) + ]); + } + }); }); describe('GET_ACTIVITIES_PARAMETERS', () => { diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index fd1cfbade3..1b200e0119 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -18,8 +18,18 @@ import { import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; 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`; + const DATA_SOURCE_PARAMETER = z - .union([z.enum(DataSource), z.string().refine(isValidEncodedDataSource)]) + .union( + [ + z.enum(DataSource), + z.string().refine(isValidEncodedDataSource, { + error: DATA_SOURCE_PARAMETER_ERROR + }) + ], + { error: DATA_SOURCE_PARAMETER_ERROR } + ) .describe('The data source of the asset profile'); const SYMBOL_PARAMETER = z 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 2b5cb34805..a013341179 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -353,14 +353,15 @@ describe('McpService', () => { }); describe('getPortfolio', () => { - it('Gives the table of the holdings in the default language', async () => { + it('Gives the table of the holdings with the data source in the default language', async () => { expect(await mcpService.getPortfolio({ userId })).toEqual({ content: [{ text: '## Holdings', type: 'text' }] }); expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); }); }); @@ -437,8 +438,6 @@ describe('McpService', () => { }); }); - // A response of the instance encodes the data source for a user who is - // not an admin, and the client of the access is not a user it('Encodes the data source if the subscription is enabled', async () => { setupUser([permissions.createActivity]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index db6e197dae..70be380060 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -121,7 +121,8 @@ export class McpService { public async getPortfolio({ userId }: { userId: string }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); return this.getTextResult(table); diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index 462803502a..79802883e8 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -115,7 +115,7 @@ export function transformDataSourceInRequest({ configurationService: ConfigurationService; dataSource?: string; }) { - if (DataSource[dataSource]) { + if (Object.hasOwn(DataSource, dataSource)) { return getUnmaskedGhostfolioDataSource({ dataSource: dataSource as DataSource, ghostfolioDataSources: getGhostfolioDataSources({ configurationService }) 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 0151971233..3cfb9fe856 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 @@ -380,9 +380,8 @@ describe('PortfolioTableService', () => { expect(result).toContain('No accounts found.'); }); }); + describe('getActivitiesTable', () => { - // The holding parameter of the tools takes the data source, which a - // response of the instance encodes, hence the table encodes it alike it('gives the data source, encoded if the subscription is enabled', async () => { const result = await createPortfolioTableService({ activities: [ @@ -411,17 +410,33 @@ describe('PortfolioTableService', () => { describe('getHoldingsTable', () => { function getHoldingsTable( holdings: PortfolioPosition[], - configuration?: Record + { + configuration, + withDataSource + }: { + configuration?: Record; + withDataSource?: boolean; + } = {} ) { return createPortfolioTableService({ configuration, holdings }).getHoldingsTable({ + withDataSource, languageCode: DEFAULT_LANGUAGE_CODE, userId: 'user-id' }); } + it('gives no data source by default', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: SUBSCRIPTION_CONFIGURATION + }); + + expect(result).not.toContain('Data Source'); + expect(result).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + it('gives the data source, encoded if the subscription is enabled', async () => { const result = await getHoldingsTable( [ @@ -432,7 +447,7 @@ describe('PortfolioTableService', () => { symbol: 'GF_GOLD' }) ], - SUBSCRIPTION_CONFIGURATION + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } ); const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { @@ -445,7 +460,8 @@ describe('PortfolioTableService', () => { it('gives the data source unencoded if the subscription is disabled', async () => { const result = await getHoldingsTable([createHolding()], { - ENABLE_FEATURE_SUBSCRIPTION: false + configuration: { ENABLE_FEATURE_SUBSCRIPTION: false }, + withDataSource: true }); const [row] = result.split('\n').filter((line) => { @@ -455,6 +471,27 @@ describe('PortfolioTableService', () => { expect(row).toContain(`| ${DataSource.YAHOO} |`); }); + it('gives no data source of a cash position', async () => { + const result = await getHoldingsTable( + [ + createHolding({ + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + dataSource: DataSource.YAHOO, + symbol: 'USD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of USD'); + }); + + expect(row).toContain('| USD | | CHF |'); + expect(row).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + it('gives the translation of the asset class and of the asset sub class', async () => { const result = await getHoldingsTable([createHolding()]); 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 b80a78afc2..9e63d133b5 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -6,7 +6,11 @@ import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-c import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isAccountExcluded, + isCashPosition +} from '@ghostfolio/common/helper'; import { Activity, Filter, @@ -19,6 +23,7 @@ import { Injectable } from '@nestjs/common'; import { AssetClass, AssetSubClass, + DataSource, Type as ActivityType } from '@prisma/client'; import { format } from 'date-fns'; @@ -26,6 +31,23 @@ import { format } from 'date-fns'; import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; +const DATA_SOURCE_COLUMN_NAME = 'Data Source'; + +function getDataSourceColumnDefinition( + getDataSource: (row: T) => DataSource | undefined +): TableColumnDefinition { + return { + getValue: (row, { configurationService }) => { + const dataSource = getDataSource(row); + + return dataSource + ? transformDataSourceInResponse({ configurationService, dataSource }) + : ''; + }, + name: DATA_SOURCE_COLUMN_NAME + }; +} + function getPercentage(value: number) { return `${(value * 100).toFixed(3)}%`; } @@ -114,15 +136,9 @@ export class PortfolioTableService { }, name: 'Symbol' }, - { - getValue: ({ assetProfile }, { configurationService }) => { - return transformDataSourceInResponse({ - configurationService, - dataSource: assetProfile.dataSource - }); - }, - name: 'Data Source' - }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return assetProfile.dataSource; + }), { getValue: ({ assetProfile, currency }) => { return currency ?? assetProfile.currency; @@ -158,15 +174,11 @@ export class PortfolioTableService { }, name: 'Symbol' }, - { - getValue: ({ assetProfile }, { configurationService }) => { - return transformDataSourceInResponse({ - configurationService, - dataSource: assetProfile.dataSource - }); - }, - name: 'Data Source' - }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return isCashPosition(assetProfile) + ? undefined + : assetProfile.dataSource; + }), { getValue: ({ assetProfile }) => { return assetProfile.currency; @@ -260,15 +272,9 @@ export class PortfolioTableService { }, name: 'Symbol' }, - { - getValue: ({ dataSource }, { configurationService }) => { - return transformDataSourceInResponse({ - configurationService, - dataSource - }); - }, - name: 'Data Source' - }, + getDataSourceColumnDefinition(({ dataSource }) => { + return dataSource; + }), { getValue: ({ trend50d }) => { return trend50d; @@ -445,11 +451,13 @@ export class PortfolioTableService { public async getHoldingsTable({ filters, languageCode, - userId + userId, + withDataSource = false }: { filters?: Filter[]; languageCode: string; userId: string; + withDataSource?: boolean; }) { const { holdings } = await this.portfolioService.getDetails({ filters, @@ -477,7 +485,11 @@ export class PortfolioTableService { '', await getMarkdownTable({ columnDefinitions: - PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( + ({ name }) => { + return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; + } + ), context: { assetClassTranslations, assetSubClassTranslations,