diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f19caded..c1d73ad59 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 support for the filters of the assistant to the accounts page +- Added a tool to get the accounts of the portfolio to the server of the Model Context Protocol (MCP) (experimental) - Extended the `GET api/v1/account` endpoint by the filters `accounts`, `assetClasses` and `tags` ### Changed diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts new file mode 100644 index 000000000..3b6479154 --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -0,0 +1,150 @@ +import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; +import { AccountWithValue } from '@ghostfolio/common/types'; + +import { AiService } from './ai.service'; + +// The service imports two packages which ship as an ECMAScript module only, +// which Jest cannot transform. The mocks only make the imports resolvable, +// because no test calls them. +jest.mock('@openrouter/ai-sdk-provider', () => { + return { createOpenRouter: jest.fn() }; +}); + +jest.mock('ai', () => { + return { generateText: jest.fn() }; +}); + +/** + * The markdown table is rendered by a package which ships as an ECMAScript + * module only, hence the service loads it with a dynamic import which Jest + * cannot run. The tests replace the method by a simple renderer, so that they + * can read the columns and the rows which the service gives to it. + */ +interface AiServiceWithMarkdownTable { + getMarkdownTable(parameters: { + columnDefinitions: readonly { name: string }[]; + rows: Record[]; + }): Promise; +} + +function createAccount({ + isExcluded = false, + name = 'Account A' +}: { + isExcluded?: boolean; + name?: string; +} = {}) { + return { + name, + activitiesCount: 3, + allocationInPercentage: 0.25, + balance: 1000, + currency: 'CHF', + platform: { name: 'Platform A' }, + tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], + value: 2000 + } as unknown as AccountWithValue; +} + +function createAiService(accounts: AccountWithValue[]) { + const portfolioService = { + getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) + } as unknown as PortfolioService; + + const aiService = new AiService(null, null, null, portfolioService, null); + + jest + .spyOn( + aiService as unknown as AiServiceWithMarkdownTable, + 'getMarkdownTable' + ) + .mockImplementation(async ({ columnDefinitions, rows }) => { + const columnNames = columnDefinitions.map(({ name }) => { + return name; + }); + + return [ + columnNames.join(' | '), + ...rows.map((row) => { + return columnNames + .map((columnName) => { + return row[columnName]; + }) + .join(' | '); + }) + ].join('\n'); + }); + + return aiService; +} + +describe('AiService', () => { + // The tools of the model context protocol are the only callers, and an + // access of that type never grants the scope to read the monetary values, + // hence no table has a column with such a value + describe('getAccountsTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(AiService.getAccountsTableColumnNames()).toEqual([ + 'Name', + 'Currency', + 'Platform', + 'Activities Count', + 'Allocation in Percentage', + 'Excluded from Analysis' + ]); + }); + }); + + describe('getActivitiesTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(AiService.getActivitiesTableColumnNames()).toEqual([ + 'Date', + 'Type', + 'Name', + 'Symbol', + 'Currency', + 'Unit Price', + 'Account' + ]); + }); + }); + + describe('getAccountsTable', () => { + it('gives no cash balance and no value of an account', async () => { + const aiService = createAiService([createAccount()]); + + const result = await aiService.getAccountsTable({ userId: 'user-id' }); + + expect(result).not.toContain('Cash Balance'); + expect(result).not.toContain('1000'); + expect(result).not.toContain('2000'); + }); + + it('marks an account which is excluded from the analysis', async () => { + const aiService = createAiService([ + createAccount({ isExcluded: true }), + createAccount({ name: 'Account B' }) + ]); + + const result = await aiService.getAccountsTable({ userId: 'user-id' }); + + const [rowOfAccountA, rowOfAccountB] = result + .split('\n') + .filter((line) => { + return line.startsWith('Account '); + }); + + expect(rowOfAccountA).toContain('true'); + expect(rowOfAccountB).toContain('false'); + }); + + it('tells that no accounts are found if the result is empty', async () => { + const aiService = createAiService([]); + + const result = await aiService.getAccountsTable({ userId: 'user-id' }); + + expect(result).toContain('No accounts found.'); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index cd5284e84..029fcc81b 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -7,7 +7,7 @@ import { PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL } from '@ghostfolio/common/config'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import type { AiPromptMode } from '@ghostfolio/common/types'; @@ -24,44 +24,43 @@ import type { ColumnDescriptor } from 'tablemark'; @Injectable() export class AiService { + private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACTIVITIES_COUNT' + | 'ALLOCATION_PERCENTAGE' + | 'CURRENCY' + | 'EXCLUDED_FROM_ANALYSIS' + | 'NAME' + | 'PLATFORM'; + } & ColumnDescriptor)[] = [ + { key: 'NAME', name: 'Name' }, + { key: 'CURRENCY', name: 'Currency' }, + { key: 'PLATFORM', name: 'Platform' }, + { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, + { + align: 'right', + key: 'ALLOCATION_PERCENTAGE', + name: 'Allocation in Percentage' + }, + { key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } + ]; + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ key: | 'ACCOUNT' | 'CURRENCY' | 'DATE' - | 'FEE' | 'NAME' - | 'QUANTITY' | 'SYMBOL' | 'TYPE' - | 'UNIT_PRICE' - | 'VALUE'; - requiresScopeToReadValues?: boolean; + | 'UNIT_PRICE'; } & 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' } ]; @@ -98,34 +97,22 @@ export class AiService { private readonly propertyService: PropertyService ) {} - public static getActivitiesTableColumnNames({ - withValues - }: { - withValues: boolean; - }) { - return AiService.getActivitiesTableColumnDefinitions({ withValues }).map( - ({ name }) => { - return name; - } - ); + public static getAccountsTableColumnNames() { + return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { + return name; + }); } - public static getHoldingsTableColumnNames() { - return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { + public static getActivitiesTableColumnNames() { + return AiService.ACTIVITIES_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 static getHoldingsTableColumnNames() { + return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { + return name; + }); } public async generateText({ @@ -154,6 +141,84 @@ export class AiService { }); } + public async getAccountsTable({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }) { + const { accounts } = + await this.portfolioService.getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts: true + }); + + const accountsTableRows = accounts.map( + ({ + activitiesCount, + allocationInPercentage, + currency, + name: label, + platform, + tags + }) => { + return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.reduce( + (row, { key, name }) => { + switch (key) { + case 'ACTIVITIES_COUNT': + row[name] = activitiesCount.toString(); + break; + + case 'ALLOCATION_PERCENTAGE': + row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; + break; + + case 'CURRENCY': + row[name] = currency ?? ''; + break; + + case 'EXCLUDED_FROM_ANALYSIS': + row[name] = isAccountExcluded({ tags }).toString(); + break; + + case 'NAME': + row[name] = label ?? ''; + break; + + case 'PLATFORM': + row[name] = platform?.name ?? ''; + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + const accountsSection = ['## Accounts', '']; + + if (accountsTableRows.length > 0) { + accountsSection.push( + await this.getMarkdownTable({ + columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, + rows: accountsTableRows + }) + ); + } else { + accountsSection.push('No accounts found.'); + } + + return accountsSection.join('\n'); + } + public async getActivitiesTable({ endDate, filters, @@ -162,8 +227,7 @@ export class AiService { take, types, userCurrency, - userId, - withValues + userId }: { endDate?: Date; filters?: Filter[]; @@ -173,7 +237,6 @@ export class AiService { types?: ActivityType[]; userCurrency: string; userId: string; - withValues: boolean; }) { const { activities, count } = await this.activitiesService.getActivities({ endDate, @@ -190,22 +253,9 @@ export class AiService { withExcludedAccountsAndActivities: true }); - const activitiesTableColumnDefinitions = - AiService.getActivitiesTableColumnDefinitions({ withValues }); - const activitiesTableRows = activities.map( - ({ - account, - assetProfile, - currency, - date, - fee, - quantity, - type, - unitPrice, - value - }) => { - return activitiesTableColumnDefinitions.reduce( + ({ account, assetProfile, currency, date, type, unitPrice }) => { + return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce( (row, { key, name }) => { switch (key) { case 'ACCOUNT': @@ -220,18 +270,10 @@ export class AiService { 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; @@ -244,10 +286,6 @@ export class AiService { row[name] = unitPrice.toString(); break; - case 'VALUE': - row[name] = value.toString(); - break; - default: row[name] = ''; break; @@ -274,7 +312,7 @@ export class AiService { activitiesSection.push( '', await this.getMarkdownTable({ - columnDefinitions: activitiesTableColumnDefinitions, + columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, rows: activitiesTableRows }) ); diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 26559c56e..f238b4890 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -10,7 +10,7 @@ import { DEFAULT_LANGUAGE_CODE, MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; -import { hasScope, scopes } from '@ghostfolio/common/scopes'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; import { UseFilters } from '@nestjs/common'; @@ -19,6 +19,23 @@ import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { McpController, Tool } from '@rekog/mcp-nest'; import { z } from 'zod'; +const GET_ACCOUNTS_PARAMETERS = z.object({ + assetClasses: z + .array(z.enum(AssetClass)) + .min(1) + .optional() + .describe('The asset classes of the accounts 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 accounts to get') +}); + const GET_ACTIVITIES_PARAMETERS = z.object({ activityTypes: z .array(z.enum(ActivityType)) @@ -71,6 +88,35 @@ export class GhostfolioMcpController { private readonly apiService: ApiService ) {} + @RequiresScopeOfAccess(scopes.accountRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get accounts' + }, + description: `Gives the accounts of the portfolio with these columns: ${AiService.getAccountsTableColumnNames().join( + ', ' + )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`, + name: 'get-accounts', + parameters: GET_ACCOUNTS_PARAMETERS + }) + public async getAccounts( + @Impersonation() { userId }: ImpersonationContext, + @Payload() + { assetClasses, holding }: z.infer + ) { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetClasses: assetClasses?.join(','), + filterByDataSource: holding?.dataSource, + filterBySymbol: holding?.symbol + }); + + const table = await this.aiService.getAccountsTable({ filters, userId }); + + return { content: [{ text: table, type: 'text' as const }] }; + } + @RequiresScopeOfAccess(scopes.activityRead) @Tool({ annotations: { @@ -78,17 +124,15 @@ export class GhostfolioMcpController { readOnlyHint: true, title: 'Get activities' }, - description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames( - { withValues: false } - ).join( + description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().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.`, + )}. 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() - { scopes: scopesOfAccess, userId, userSettings }: ImpersonationContext, + { userId, userSettings }: ImpersonationContext, @Payload() { activityTypes, @@ -122,8 +166,7 @@ export class GhostfolioMcpController { userId, take: take ?? MCP_MAX_ACTIVITIES, types: activityTypes, - userCurrency: userSettings.baseCurrency, - withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues) + userCurrency: userSettings.baseCurrency }); return { content: [{ text: table, type: 'text' as const }] };