Browse Source

Bugfix/holding filter of MCP tools (#7999)

* Fix filtering by holding

* Update changelog
pull/8001/head
Thomas Kaul 7 days ago
committed by GitHub
parent
commit
8ea8117486
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 40
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  3. 10
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  4. 54
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 40
      apps/api/src/app/endpoints/mcp/mcp.service.ts

4
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

40
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;

10
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({

54
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' });

40
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -41,10 +41,9 @@ export class McpService {
userId
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { 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<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {
return {
filterByDataSource: getUnmaskedGhostfolioDataSource({
dataSource: holding?.dataSource,
ghostfolioDataSources: this.getGhostfolioDataSources()
}),
filterBySymbol: holding?.symbol
};
}
private getTextResult(text: string) {
return { content: [{ text, type: 'text' as const }] };
}

Loading…
Cancel
Save