From 88ea00965e6ae4f72c57b115e98da3bf41ac1fec Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:46:43 +0200 Subject: [PATCH 1/2] Add MCP tool to get accounts --- apps/api/src/app/endpoints/ai/ai.service.ts | 167 ++++++++++++++++-- .../src/app/endpoints/mcp/mcp.controller.ts | 27 +++ 2 files changed, 182 insertions(+), 12 deletions(-) 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 9e6f29e52..ca09ea343 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -71,6 +71,33 @@ 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' + }) + public async getAccounts( + @Impersonation() + { filters, scopes: scopesOfAccess, userId }: ImpersonationContext + ) { + 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: { From 0e74d05d79ab4d293bcf6a8e0fbe35c27ef9658e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:47:03 +0200 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a86575b0b..bc44b7b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added a tool to get the accounts of the portfolio to the server of the Model Context Protocol (MCP) (experimental) + ## 3.63.0 - 2026-08-28 ### Added