From 22b7b6396ade078b6a7103b2127005b5ce5e7dea Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:33:12 +0200 Subject: [PATCH] Feature/add MCP tool to search asset profiles (#7849) * Add MCP tool to search asset profiles * Update changelog --- CHANGELOG.md | 4 + .../app/endpoints/mcp/mcp.controller.spec.ts | 6 ++ .../src/app/endpoints/mcp/mcp.controller.ts | 24 ++++- apps/api/src/app/endpoints/mcp/mcp.module.ts | 2 + .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 47 +++++++++- apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 15 +++- .../src/app/endpoints/mcp/mcp.service.spec.ts | 87 ++++++++++++++++++- apps/api/src/app/endpoints/mcp/mcp.service.ts | 60 ++++++++++++- 8 files changed, 235 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb6b1762..be89cc1ed 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 search for asset profiles to the server of the Model Context Protocol (MCP) (experimental) + ### Changed - Improved the style of the activity type filter on the activities page (experimental) 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 741e2a1ae..f6e1d0438 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -77,6 +77,12 @@ describe('GhostfolioMcpController', () => { ).toEqual([scopes.activityCreate]); }); + it('Requires the scope to create an activity for the tool to search asset profiles', () => { + expect( + getMetadataOfMethod(REQUIRES_SCOPE_KEY, 'searchAssetProfiles') + ).toEqual([scopes.activityCreate]); + }); + // The tools have no try and catch, hence the filter is the only guarantee // that an unexpected exception does not expose internals it('Applies the filter of the exceptions of the tools', () => { diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 6b838c108..3fe90c4dc 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -15,7 +15,8 @@ import 'zod/compile'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, - IMPORT_ACTIVITIES_PARAMETERS + IMPORT_ACTIVITIES_PARAMETERS, + SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; import { McpService } from './mcp.service'; @@ -98,7 +99,7 @@ export class GhostfolioMcpController { readOnlyHint: false, title: 'Import activities' }, - description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, + description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, name: 'import-activities', parameters: IMPORT_ACTIVITIES_PARAMETERS }) @@ -108,4 +109,23 @@ export class GhostfolioMcpController { ) { return this.mcpService.importActivities({ ...parameters, userId }); } + + @RequiresScopeOfAccess(scopes.activityCreate) + @Tool({ + annotations: { + openWorldHint: true, + readOnlyHint: true, + title: 'Search asset profiles' + }, + description: + 'Searches for financial assets, such as stocks, ETFs, cryptocurrencies, mutual funds and commodities, which are available to the user. Each result is an asset profile that can be used to import an activity. Use this before importing an activity unless the exact symbol and data source are already known. Select the candidate that matches the intended asset and pass its symbol, dataSource and currency unchanged to import-activities.', + name: 'search-asset-profiles', + parameters: SEARCH_ASSET_PROFILES_PARAMETERS + }) + public async searchAssetProfiles( + @Impersonation() { userId }: ImpersonationContext, + @Payload() parameters: z.infer + ) { + return this.mcpService.searchAssetProfiles({ ...parameters, userId }); + } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index b782736c8..33cf66711 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,4 +1,5 @@ import { ImportModule } from '@ghostfolio/api/app/import/import.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { environment } from '@ghostfolio/api/environments/environment'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; @@ -24,6 +25,7 @@ import { McpService } from './mcp.service'; ConfigurationModule, ImportModule, PortfolioTableModule, + SymbolModule, UserModule ], providers: [ 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 2d38923b9..c946a9d13 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,6 +1,13 @@ -import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; +import { + MCP_MAX_ACTIVITIES, + SEARCH_QUERY_MAXIMUM_LENGTH, + SEARCH_QUERY_MINIMUM_LENGTH +} from '@ghostfolio/common/config'; -import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; +import { + IMPORT_ACTIVITIES_PARAMETERS, + SEARCH_ASSET_PROFILES_PARAMETERS +} from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; describe('IMPORT_ACTIVITIES_PARAMETERS', () => { @@ -46,3 +53,39 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { ).toBe(false); }); }); + +describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => { + it('Refuses a query that contains only spaces', () => { + expect( + SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: ' ' }).success + ).toBe(false); + }); + + it(`Refuses a query shorter than ${SEARCH_QUERY_MINIMUM_LENGTH} characters`, () => { + expect( + SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: 'A' }).success + ).toBe(false); + }); + + it(`Refuses a query longer than ${SEARCH_QUERY_MAXIMUM_LENGTH} characters`, () => { + expect( + SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ + query: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) + }).success + ).toBe(false); + }); + + it('Accepts a name, symbol or ISIN', () => { + for (const query of ['Apple', 'AAPL', 'US0378331005']) { + expect( + SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query }).success + ).toBe(true); + } + }); + + it('Removes spaces at the start and the end of a query', () => { + expect( + SEARCH_ASSET_PROFILES_PARAMETERS.parse({ query: ' Apple ' }).query + ).toBe('Apple'); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index 44568890a..2eb59264b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -2,7 +2,9 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DATE_RANGES, MCP_MAX_ACCOUNTS, - MCP_MAX_ACTIVITIES + MCP_MAX_ACTIVITIES, + SEARCH_QUERY_MAXIMUM_LENGTH, + SEARCH_QUERY_MINIMUM_LENGTH } from '@ghostfolio/common/config'; import { isValidCurrencyCode, @@ -113,3 +115,14 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .max(MCP_MAX_ACTIVITIES) .describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`) }); + +export const SEARCH_ASSET_PROFILES_PARAMETERS = z.object({ + query: z + .string() + .trim() + .min(SEARCH_QUERY_MINIMUM_LENGTH) + .max(SEARCH_QUERY_MAXIMUM_LENGTH) + .describe( + 'The name, ticker symbol or ISIN of the financial asset, for example Apple, AAPL, Bitcoin or US0378331005' + ) +}); 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 ac074c791..2ec4947c1 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -1,5 +1,6 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; import { ImportService } from '@ghostfolio/api/app/import/import.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -13,7 +14,12 @@ import { permissions } from '@ghostfolio/common/permissions'; import type { UserWithSettings } from '@ghostfolio/common/types'; import { HttpException } from '@nestjs/common'; -import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + DataSource, + Type as ActivityType +} from '@prisma/client'; import { McpService } from './mcp.service'; import { createActivity } from './mcp.test-utils'; @@ -29,12 +35,18 @@ describe('McpService', () => { let importService: ImportService; let mcpService: McpService; let portfolioTableService: PortfolioTableService; + let symbolService: SymbolService; let userService: UserService; function setupUser(userPermissions: string[]) { - jest.spyOn(userService, 'user').mockResolvedValue({ + const user = { + id: userId, permissions: userPermissions - } as UserWithSettings); + } as UserWithSettings; + + jest.spyOn(userService, 'user').mockResolvedValue(user); + + return user; } beforeEach(() => { @@ -63,6 +75,10 @@ describe('McpService', () => { getHoldingsTable: jest.fn().mockResolvedValue('## Holdings') } as unknown as PortfolioTableService; + symbolService = { + lookup: jest.fn().mockResolvedValue({ items: [] }) + } as unknown as SymbolService; + userService = { user: jest.fn() } as unknown as UserService; mcpService = new McpService( @@ -70,6 +86,7 @@ describe('McpService', () => { configurationService, importService, portfolioTableService, + symbolService, userService ); }); @@ -182,6 +199,70 @@ describe('McpService', () => { }); }); + describe('searchAssetProfiles', () => { + it('Refuses a user without the permission to create an activity', async () => { + setupUser([]); + + await expect( + mcpService.searchAssetProfiles({ query: 'Apple', userId }) + ).rejects.toThrow(HttpException); + + expect(symbolService.lookup).not.toHaveBeenCalled(); + }); + + it('Gives the import-ready asset profiles available to the user', async () => { + const user = setupUser([permissions.createActivity]); + + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + jest.spyOn(symbolService, 'lookup').mockResolvedValue({ + items: [ + { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + currency: 'USD', + dataProviderInfo: { isPremium: false }, + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + }, + { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + currency: 'USD', + dataProviderInfo: { isPremium: true }, + dataSource: DataSource.GHOSTFOLIO, + name: 'Premium asset', + symbol: 'PREMIUM' + } + ] + }); + + const result = await mcpService.searchAssetProfiles({ + query: 'Apple', + userId + }); + + expect(symbolService.lookup).toHaveBeenCalledWith({ + query: 'Apple', + user + }); + expect(JSON.parse(result.content[0].text)).toEqual({ + assetProfiles: [ + { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + currency: 'USD', + dataSource: DataSource.GHOSTFOLIO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + ] + }); + }); + }); + describe('importActivities', () => { 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 3cd95aaa0..1bf25cdcc 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -1,6 +1,10 @@ import { ImportService } from '@ghostfolio/api/app/import/import.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; +import { + getMaskedGhostfolioDataSource, + getUnmaskedGhostfolioDataSource +} from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; @@ -15,7 +19,8 @@ import { z } from 'zod'; import { GET_ACCOUNTS_PARAMETERS, GET_ACTIVITIES_PARAMETERS, - IMPORT_ACTIVITIES_PARAMETERS + IMPORT_ACTIVITIES_PARAMETERS, + SEARCH_ASSET_PROFILES_PARAMETERS } from './mcp.schemas'; @Injectable() @@ -25,6 +30,7 @@ export class McpService { private readonly configurationService: ConfigurationService, private readonly importService: ImportService, private readonly portfolioTableService: PortfolioTableService, + private readonly symbolService: SymbolService, private readonly userService: UserService ) {} @@ -146,6 +152,56 @@ export class McpService { return this.getTextResult(text); } + public async searchAssetProfiles({ + query, + userId + }: z.infer & { userId: string }) { + const user = await this.getUserWithPermission({ + userId, + permission: permissions.createActivity + }); + + 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 assetProfiles = items.flatMap( + ({ + assetClass, + assetSubClass, + currency, + dataProviderInfo, + dataSource, + name, + symbol + }) => { + if (!dataSource || dataProviderInfo.isPremium) { + return []; + } + + return [ + { + assetClass, + assetSubClass, + currency, + name, + symbol, + dataSource: getMaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources + }) + } + ]; + } + ); + + return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); + } + private getTextResult(text: string) { return { content: [{ text, type: 'text' as const }] }; }