From e2a6048d1dba2826bb1cb88fce3422d91c50eda7 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:33:30 +0200 Subject: [PATCH] Feature/add MCP tool to get activities (#7742) * Add MCP tool to get activities * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/endpoints/ai/ai.service.ts | 274 +++++++++++++++++- .../src/app/endpoints/mcp/mcp.controller.ts | 173 ++++++++++- apps/api/src/app/endpoints/mcp/mcp.module.ts | 5 +- libs/common/src/lib/config.ts | 1 + 5 files changed, 434 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 845b15d3b..85673dcf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the _Restricted view and manage_ permission to the access to share the portfolio (experimental) +- Added a tool to get the activities of the portfolio to the server of the Model Context Protocol (MCP) (experimental) ### Changed diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 3284d2a15..cd5284e84 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -1,3 +1,4 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; @@ -12,13 +13,58 @@ import type { AiPromptMode } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { AssetClass, AssetSubClass } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; import { generateText } from 'ai'; import { format } from 'date-fns'; import type { ColumnDescriptor } from 'tablemark'; @Injectable() export class AiService { + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACCOUNT' + | 'CURRENCY' + | 'DATE' + | 'FEE' + | 'NAME' + | 'QUANTITY' + | 'SYMBOL' + | 'TYPE' + | 'UNIT_PRICE' + | 'VALUE'; + requiresScopeToReadValues?: boolean; + } & ColumnDescriptor)[] = [ + { key: 'DATE', name: 'Date' }, + { key: 'TYPE', name: 'Type' }, + { key: 'NAME', name: 'Name' }, + { key: 'SYMBOL', name: 'Symbol' }, + { key: 'CURRENCY', name: 'Currency' }, + { + align: 'right', + key: 'QUANTITY', + name: 'Quantity', + requiresScopeToReadValues: true + }, + { align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, + { + align: 'right', + key: 'FEE', + name: 'Fee', + requiresScopeToReadValues: true + }, + { + align: 'right', + key: 'VALUE', + name: 'Value', + requiresScopeToReadValues: true + }, + { key: 'ACCOUNT', name: 'Account' } + ]; + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ key: | 'ACTIVITIES_COUNT' @@ -45,18 +91,43 @@ export class AiService { ]; public constructor( + private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService, private readonly portfolioService: PortfolioService, private readonly propertyService: PropertyService ) {} + public static getActivitiesTableColumnNames({ + withValues + }: { + withValues: boolean; + }) { + return AiService.getActivitiesTableColumnDefinitions({ withValues }).map( + ({ name }) => { + return name; + } + ); + } + public static getHoldingsTableColumnNames() { return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { return name; }); } + private static getActivitiesTableColumnDefinitions({ + withValues + }: { + withValues: boolean; + }) { + return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter( + ({ requiresScopeToReadValues }) => { + return withValues || !requiresScopeToReadValues; + } + ); + } + public async generateText({ prompt, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') @@ -83,6 +154,135 @@ export class AiService { }); } + public async getActivitiesTable({ + endDate, + filters, + skip = 0, + startDate, + take, + types, + userCurrency, + userId, + withValues + }: { + endDate?: Date; + filters?: Filter[]; + skip?: number; + startDate?: Date; + take: number; + types?: ActivityType[]; + userCurrency: string; + userId: string; + withValues: boolean; + }) { + const { activities, count } = await this.activitiesService.getActivities({ + endDate, + filters, + skip, + startDate, + take, + types, + userCurrency, + userId, + includeDrafts: true, + sortColumn: 'date', + sortDirection: 'desc', + withExcludedAccountsAndActivities: true + }); + + const activitiesTableColumnDefinitions = + AiService.getActivitiesTableColumnDefinitions({ withValues }); + + const activitiesTableRows = activities.map( + ({ + account, + assetProfile, + currency, + date, + fee, + quantity, + type, + unitPrice, + value + }) => { + return activitiesTableColumnDefinitions.reduce( + (row, { key, name }) => { + switch (key) { + case 'ACCOUNT': + row[name] = account?.name ?? ''; + break; + + case 'CURRENCY': + row[name] = currency ?? assetProfile.currency; + break; + + case 'DATE': + row[name] = format(date, DATE_FORMAT); + break; + + case 'FEE': + row[name] = fee.toString(); + break; + + case 'NAME': + row[name] = assetProfile.name ?? ''; + break; + + case 'QUANTITY': + row[name] = quantity.toString(); + break; + + case 'SYMBOL': + row[name] = assetProfile.symbol; + break; + + case 'TYPE': + row[name] = type; + break; + + case 'UNIT_PRICE': + row[name] = unitPrice.toString(); + break; + + case 'VALUE': + row[name] = value.toString(); + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + const activitiesSection = [ + '## Activities', + '', + this.getActivitiesSummary({ + count, + skip, + numberOfActivities: activities.length + }) + ]; + + if (activitiesTableRows.length > 0) { + activitiesSection.push( + '', + await this.getMarkdownTable({ + columnDefinitions: activitiesTableColumnDefinitions, + rows: activitiesTableRows + }) + ); + } + + return activitiesSection.join('\n'); + } + public async getPrompt({ filters, languageCode, @@ -101,11 +301,6 @@ export class AiService { userId }); - const holdingsTableColumns: ColumnDescriptor[] = - AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => { - return { name, align: align ?? 'left' }; - }); - const assetClassTranslations = this.getEnumTranslations({ languageCode, id: 'assetClass', @@ -184,18 +379,12 @@ export class AiService { } ); - // Dynamic import to load ESM module from CommonJS context - // eslint-disable-next-line @typescript-eslint/no-implied-eval - const dynamicImport = new Function('s', 'return import(s)') as ( - s: string - ) => Promise; - const { tablemark } = await dynamicImport('tablemark'); - const holdingsSection = [ '## Holdings', '', - tablemark(holdingsTableRows, { - columns: holdingsTableColumns + await this.getMarkdownTable({ + columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, + rows: holdingsTableRows }) ].join('\n'); @@ -218,6 +407,40 @@ export class AiService { ].join('\n'); } + private getActivitiesSummary({ + count, + numberOfActivities, + skip + }: { + count: number; + numberOfActivities: number; + skip: number; + }) { + if (count === 0) { + return 'No activities found.'; + } + + if (numberOfActivities === 0) { + return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`; + } + + if (numberOfActivities === count) { + return `Showing all ${count} activities, the most recent first.`; + } + + const lastActivity = skip + numberOfActivities; + + const summary = `Showing the activities ${ + skip + 1 + } to ${lastActivity} of ${count}, the most recent first.`; + + if (lastActivity === count) { + return summary; + } + + return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; + } + private getEnumTranslations({ id, languageCode, @@ -240,4 +463,25 @@ export class AiService { {} as Record ); } + + private async getMarkdownTable({ + columnDefinitions, + rows + }: { + columnDefinitions: readonly ColumnDescriptor[]; + rows: Record[]; + }) { + // Dynamic import to load ESM module from CommonJS context + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('s', 'return import(s)') as ( + s: string + ) => Promise; + const { tablemark } = await dynamicImport('tablemark'); + + return tablemark(rows, { + columns: columnDefinitions.map(({ align, name }) => { + return { name, align: align ?? 'left' }; + }) + }); + } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 3fc93219a..9e6f29e52 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -1,18 +1,185 @@ import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; +import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; -import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { scopes } from '@ghostfolio/common/scopes'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + DATE_RANGES, + DEFAULT_LANGUAGE_CODE, + MCP_MAX_ACTIVITIES +} from '@ghostfolio/common/config'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; import { UseFilters } from '@nestjs/common'; +import { Payload } from '@nestjs/microservices'; +import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { McpController, Tool } from '@rekog/mcp-nest'; +import { z } from 'zod'; + +const GET_ACTIVITIES_PARAMETERS = z.object({ + activityTypes: z + .array(z.enum(ActivityType)) + .min(1) + .optional() + .describe('The types of the activities to get'), + assetClasses: z + .array(z.enum(AssetClass)) + .min(1) + .optional() + .describe('The asset classes of the activities to get'), + holding: z + .object({ + dataSource: z + .enum(DataSource) + .describe('The data source of the asset profile'), + symbol: z.string().describe('The symbol of the asset profile') + }) + .optional() + .describe('The asset profile of the activities to get'), + range: z + .string() + .regex(DATE_RANGE_PATTERN) + .optional() + .describe( + `The date range of the activities to get, either ${DATE_RANGES.join( + ', ' + )} or a calendar year like 2024` + ), + skip: z + .number() + .int() + .min(0) + .optional() + .describe('The number of activities to skip'), + take: z + .number() + .int() + .min(1) + .max(MCP_MAX_ACTIVITIES) + .optional() + .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) +}); @McpController() @UseFilters(McpToolExceptionFilter) export class GhostfolioMcpController { - public constructor(private readonly aiService: AiService) {} + public constructor( + private readonly aiService: AiService, + private readonly apiService: ApiService + ) {} + + @RequiresScopeOfAccess(scopes.activityRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get activities' + }, + description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames( + { withValues: false } + ).join( + ', ' + )}. More columns with a monetary value are added if the access grants to read them. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, + name: 'get-activities', + parameters: GET_ACTIVITIES_PARAMETERS + }) + public async getActivities( + @Impersonation() + { + filters, + scopes: scopesOfAccess, + userId, + userSettings + }: ImpersonationContext, + @Payload() + { + activityTypes, + assetClasses, + holding, + range, + skip, + take + }: z.infer + ) { + let endDate: Date; + let startDate: Date; + + if (range) { + ({ endDate, startDate } = getIntervalFromDateRange({ + dateRange: range + })); + } + + const filtersOfAccess = filters ?? []; + + const filtersOfTool = this.apiService.buildFiltersFromQueryParams({ + filterByAssetClasses: assetClasses?.join(','), + filterByDataSource: holding?.dataSource, + filterBySymbol: holding?.symbol + }); + + // A tool must never widen the access, hence a filter of the tool which + // the access does not permit gives no activity + const filtersOfToolOutsideAccess = filtersOfTool.filter(({ id, type }) => { + const filtersOfAccessOfType = filtersOfAccess.filter((filter) => { + return filter.type === type; + }); + + return ( + filtersOfAccessOfType.length > 0 && + !filtersOfAccessOfType.some((filter) => { + return filter.id === id; + }) + ); + }); + + if (filtersOfToolOutsideAccess.length > 0) { + const valuesOutsideAccess = filtersOfToolOutsideAccess + .map(({ id }) => { + return id; + }) + .join(', '); + + return { + content: [ + { + text: `No activities found. The access does not permit these values of the parameters: ${valuesOutsideAccess}.`, + type: 'text' as const + } + ] + }; + } + + const typesOfFiltersOfTool = new Set( + filtersOfTool.map(({ type }) => { + return type; + }) + ); + + // The filters of a type are combined with a logical or, hence a filter of + // the tool replaces the filters of the access of the same type instead of + // joining them + const filtersOfAccessOutsideTool = filtersOfAccess.filter(({ type }) => { + return !typesOfFiltersOfTool.has(type); + }); + + const table = await this.aiService.getActivitiesTable({ + endDate, + skip, + startDate, + userId, + filters: [...filtersOfAccessOutsideTool, ...filtersOfTool], + take: take ?? MCP_MAX_ACTIVITIES, + types: activityTypes, + userCurrency: userSettings.baseCurrency, + withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues) + }); + + return { content: [{ text: table, type: 'text' as const }] }; + } @RequiresScopeOfAccess(scopes.portfolioRead) @Tool({ diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index e77bc3b19..a29fae619 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,5 +1,6 @@ import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { environment } from '@ghostfolio/api/environments/environment'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { MCP_ENDPOINT } from '@ghostfolio/common/config'; @@ -15,7 +16,7 @@ import { GhostfolioMcpController } from './mcp.controller'; @Module({ controllers: [GhostfolioMcpController], - imports: [AiModule, ConfigurationModule], + imports: [AiModule, ApiModule, ConfigurationModule], providers: [ { inject: [ConfigurationService], @@ -25,7 +26,7 @@ import { GhostfolioMcpController } from './mcp.controller'; return new McpStrategy({ instructions: - 'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access. They give no monetary value.', + 'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access. They give no quantity and no monetary value (except the unit price of an activity).', name: 'ghostfolio', title: 'Ghostfolio', transports: [ diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 228503042..f90d1e663 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -269,6 +269,7 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED = export const MAX_TOP_HOLDINGS = 50; export const MCP_ENDPOINT = '/mcp'; +export const MCP_MAX_ACTIVITIES = 100; export const MCP_REALM = 'Ghostfolio'; export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100;