From 8ea81174860058ecf829fc9c99fc4d044e67da6b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:05 +0200 Subject: [PATCH] Bugfix/holding filter of MCP tools (#7999) * Fix filtering by holding * Update changelog --- CHANGELOG.md | 4 ++ .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 40 ++++++++++++++ apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 10 +++- .../src/app/endpoints/mcp/mcp.service.spec.ts | 54 +++++++++++++++++++ apps/api/src/app/endpoints/mcp/mcp.service.ts | 40 ++++++++------ 5 files changed, 130 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4b4ca6df..e81086871d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `prettier` from version `3.9.6` to `3.9.9` +### Fixed + +- Fixed the filtering by holding in the server of the Model Context Protocol (MCP) (experimental) + ## 3.76.0 - 2026-09-30 ### Added 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 c946a9d131..6fb9086d49 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -4,12 +4,52 @@ import { SEARCH_QUERY_MINIMUM_LENGTH } from '@ghostfolio/common/config'; +import { DataSource } from '@prisma/client'; + import { + GET_ACCOUNTS_PARAMETERS, + GET_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS, SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; +describe('GET_ACCOUNTS_PARAMETERS', () => { + it('Refuses an empty symbol of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: '' } + }).success + ).toBe(false); + }); + + it('Refuses a symbol of the holding that contains only spaces', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: ' ' } + }).success + ).toBe(false); + }); +}); + +describe('GET_ACTIVITIES_PARAMETERS', () => { + it('Refuses an empty symbol of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: '' } + }).success + ).toBe(false); + }); + + it('Refuses a symbol of the holding that contains only spaces', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: DataSource.YAHOO, symbol: ' ' } + }).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 2eb59264b3..52c95471f2 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -4,7 +4,8 @@ import { MCP_MAX_ACCOUNTS, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, - SEARCH_QUERY_MINIMUM_LENGTH + SEARCH_QUERY_MINIMUM_LENGTH, + SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; import { isValidCurrencyCode, @@ -18,7 +19,12 @@ const HOLDING_PARAMETER = z.object({ dataSource: z .enum(DataSource) .describe('The data source of the asset profile'), - symbol: z.string().describe('The symbol of the asset profile') + symbol: z + .string() + .trim() + .min(1) + .max(SYMBOL_MAXIMUM_LENGTH) + .describe('The symbol of the asset profile') }); export const GET_ACCOUNTS_PARAMETERS = z.object({ 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 a29daaba93..6167d0421a 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -113,6 +113,34 @@ describe('McpService', () => { }); }); + it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getAccounts({ + userId, + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Keeps the data source if the subscription is not enabled', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = false; + + await mcpService.getAccounts({ + userId, + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.GHOSTFOLIO }) + ); + }); + it('Gives the table of the accounts of the filters', async () => { expect(await mcpService.getAccounts({ userId })).toEqual({ content: [{ text: '## Accounts', type: 'text' }] @@ -150,6 +178,32 @@ describe('McpService', () => { }); }); + it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await getActivities({ + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + + it('Keeps the data source if the subscription is not enabled', async () => { + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = false; + + await getActivities({ + holding: { dataSource: DataSource.GHOSTFOLIO, symbol: 'AAPL' } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.GHOSTFOLIO }) + ); + }); + it('Changes the range into the start date and the end date', async () => { await getActivities({ range: '2024' }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 474c62e69c..cd0edb548d 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -41,10 +41,9 @@ export class McpService { userId }: z.infer & { userId: string }) { const filters = this.apiService.buildFiltersFromQueryParams({ + ...this.getHoldingFilterParameters({ holding }), filterByAccounts: accountIds, - filterByAssetClasses: assetClasses, - filterByDataSource: holding?.dataSource, - filterBySymbol: holding?.symbol + filterByAssetClasses: assetClasses }); const table = await this.portfolioTableService.getAccountsTable({ @@ -78,9 +77,8 @@ export class McpService { } const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAssetClasses: assetClasses, - filterByDataSource: holding?.dataSource, - filterBySymbol: holding?.symbol + ...this.getHoldingFilterParameters({ holding }), + filterByAssetClasses: assetClasses }); const table = await this.portfolioTableService.getActivitiesTable({ @@ -123,11 +121,7 @@ export class McpService { permission: permissions.createActivity }); - const ghostfolioDataSources = this.configurationService.get( - 'ENABLE_FEATURE_SUBSCRIPTION' - ) - ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') - : []; + const ghostfolioDataSources = this.getGhostfolioDataSources(); const activitiesDto = activities.map((activity) => { return { @@ -171,11 +165,7 @@ export class McpService { const { items } = await this.symbolService.lookup({ query, user }); - const ghostfolioDataSources = this.configurationService.get( - 'ENABLE_FEATURE_SUBSCRIPTION' - ) - ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') - : []; + const ghostfolioDataSources = this.getGhostfolioDataSources(); const assetProfiles = items.flatMap( ({ @@ -210,6 +200,24 @@ export class McpService { return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); } + private getGhostfolioDataSources() { + return this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') + ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + : []; + } + + private getHoldingFilterParameters({ + holding + }: Pick, 'holding'>) { + return { + filterByDataSource: getUnmaskedGhostfolioDataSource({ + dataSource: holding?.dataSource, + ghostfolioDataSources: this.getGhostfolioDataSources() + }), + filterBySymbol: holding?.symbol + }; + } + private getTextResult(text: string) { return { content: [{ text, type: 'text' as const }] }; }