From dc246e80bf068fe7c3c0d6788f4d0420a8db6875 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:53:45 +0200 Subject: [PATCH] Add MCP tool to get accounts --- .../src/app/endpoints/ai/ai.service.spec.ts | 46 +++++ apps/api/src/app/endpoints/ai/ai.service.ts | 167 ++++++++++++++++-- .../src/app/endpoints/mcp/mcp.controller.ts | 53 ++++++ 3 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/app/endpoints/ai/ai.service.spec.ts 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..4c010aa40 --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -0,0 +1,46 @@ +import { AiService } from './ai.service'; + +// The service imports two packages which ship as an ECMAScript module only, +// which Jest cannot transform. The tests use a static method which does not +// call them, hence the mocks only make the imports resolvable. +jest.mock('@openrouter/ai-sdk-provider', () => { + return { createOpenRouter: jest.fn() }; +}); + +jest.mock('ai', () => { + return { generateText: jest.fn() }; +}); + +describe('AiService', () => { + describe('getAccountsTableColumnNames', () => { + it('omits the columns with a monetary value if the access does not grant to read them', () => { + const result = AiService.getAccountsTableColumnNames({ + withValues: false + }); + + expect(result).toEqual([ + 'Name', + 'Currency', + 'Platform', + 'Activities Count', + 'Allocation in Percentage' + ]); + }); + + it('gives the columns with a monetary value if the access grants to read them', () => { + const result = AiService.getAccountsTableColumnNames({ + withValues: true + }); + + expect(result).toEqual([ + 'Name', + 'Currency', + 'Platform', + 'Activities Count', + 'Cash Balance', + 'Value', + 'Allocation in Percentage' + ]); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index cd5284e84..2a00ee7e7 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -24,6 +24,40 @@ import type { ColumnDescriptor } from 'tablemark'; @Injectable() export class AiService { + private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ACTIVITIES_COUNT' + | 'ALLOCATION_PERCENTAGE' + | 'BALANCE' + | 'CURRENCY' + | 'NAME' + | 'PLATFORM' + | 'VALUE'; + requiresScopeToReadValues?: boolean; + } & ColumnDescriptor)[] = [ + { key: 'NAME', name: 'Name' }, + { key: 'CURRENCY', name: 'Currency' }, + { key: 'PLATFORM', name: 'Platform' }, + { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, + { + align: 'right', + key: 'BALANCE', + name: 'Cash Balance', + requiresScopeToReadValues: true + }, + { + align: 'right', + key: 'VALUE', + name: 'Value', + requiresScopeToReadValues: true + }, + { + align: 'right', + key: 'ALLOCATION_PERCENTAGE', + name: 'Allocation in Percentage' + } + ]; + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ key: | 'ACCOUNT' @@ -98,16 +132,30 @@ export class AiService { private readonly propertyService: PropertyService ) {} + public static getAccountsTableColumnNames({ + withValues + }: { + withValues: boolean; + }) { + return AiService.getTableColumnDefinitions({ + withValues, + columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS + }).map(({ name }) => { + return name; + }); + } + public static getActivitiesTableColumnNames({ withValues }: { withValues: boolean; }) { - return AiService.getActivitiesTableColumnDefinitions({ withValues }).map( - ({ name }) => { - return name; - } - ); + return AiService.getTableColumnDefinitions({ + withValues, + columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS + }).map(({ name }) => { + return name; + }); } public static getHoldingsTableColumnNames() { @@ -116,16 +164,18 @@ export class AiService { }); } - private static getActivitiesTableColumnDefinitions({ + private static getTableColumnDefinitions< + T extends { requiresScopeToReadValues?: boolean } + >({ + columnDefinitions, withValues }: { + columnDefinitions: readonly T[]; withValues: boolean; }) { - return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter( - ({ requiresScopeToReadValues }) => { - return withValues || !requiresScopeToReadValues; - } - ); + return columnDefinitions.filter(({ requiresScopeToReadValues }) => { + return withValues || !requiresScopeToReadValues; + }); } public async generateText({ @@ -154,6 +204,96 @@ export class AiService { }); } + public async getAccountsTable({ + filters, + userId, + withValues + }: { + filters?: Filter[]; + userId: string; + withValues: boolean; + }) { + const { accounts } = + await this.portfolioService.getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts: true + }); + + const accountsTableColumnDefinitions = AiService.getTableColumnDefinitions({ + withValues, + columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS + }); + + const accountsTableRows = accounts.map( + ({ + activitiesCount, + allocationInPercentage, + balance, + currency, + name: label, + platform, + value + }) => { + return accountsTableColumnDefinitions.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 'BALANCE': + row[name] = balance.toString(); + break; + + case 'CURRENCY': + row[name] = currency ?? ''; + break; + + case 'NAME': + row[name] = label ?? ''; + break; + + case 'PLATFORM': + row[name] = platform?.name ?? ''; + break; + + case 'VALUE': + row[name] = value.toString(); + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + const accountsSection = ['## Accounts', '']; + + if (accountsTableRows.length > 0) { + accountsSection.push( + await this.getMarkdownTable({ + columnDefinitions: accountsTableColumnDefinitions, + rows: accountsTableRows + }) + ); + } else { + accountsSection.push('No accounts found.'); + } + + return accountsSection.join('\n'); + } + public async getActivitiesTable({ endDate, filters, @@ -191,7 +331,10 @@ export class AiService { }); const activitiesTableColumnDefinitions = - AiService.getActivitiesTableColumnDefinitions({ withValues }); + AiService.getTableColumnDefinitions({ + withValues, + columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS + }); const activitiesTableRows = activities.map( ({ diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 114a9c58f..c18455844 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -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,42 @@ 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( + { withValues: false } + ).join( + ', ' + )}. More columns with a monetary value are added if the access grants to read them.`, + name: 'get-accounts', + parameters: GET_ACCOUNTS_PARAMETERS + }) + public async getAccounts( + @Impersonation() + { scopes: scopesOfAccess, 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, + withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues) + }); + + return { content: [{ text: table, type: 'text' as const }] }; + } + @RequiresScopeOfAccess(scopes.activityRead) @Tool({ annotations: {