From a36b7f9ea4d49c98fb850290eddabbc86fc06501 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:25:55 +0200 Subject: [PATCH] Feature/extend MCP tool to get accounts by account id (#7765) * Extend MCP tool to get accounts by account id * Update changelog --- CHANGELOG.md | 1 + .../src/app/endpoints/ai/ai.service.spec.ts | 18 ++++++++++++++++-- apps/api/src/app/endpoints/ai/ai.service.ts | 7 +++++++ .../src/app/endpoints/mcp/mcp.controller.ts | 16 +++++++++++++++- libs/common/src/lib/config.ts | 1 + 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9a6de0e..32ecd7b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the tool to get the accounts of the portfolio in the server of the Model Context Protocol (MCP) to support the filtering by account (experimental) - Upgraded `uuid` from version `14.0.1` to `14.0.2` ## 3.64.0 - 2026-08-30 diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 3b6479154..6328df195 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -29,13 +29,16 @@ interface AiServiceWithMarkdownTable { } function createAccount({ + id = 'account-a-id', isExcluded = false, name = 'Account A' }: { + id?: string; isExcluded?: boolean; name?: string; } = {}) { return { + id, name, activitiesCount: 3, allocationInPercentage: 0.25, @@ -86,6 +89,7 @@ describe('AiService', () => { describe('getAccountsTableColumnNames', () => { it('gives no column with a monetary value', () => { expect(AiService.getAccountsTableColumnNames()).toEqual([ + 'Id', 'Name', 'Currency', 'Platform', @@ -121,10 +125,20 @@ describe('AiService', () => { expect(result).not.toContain('2000'); }); + // The accountIds parameter of the tool takes the identifiers, hence the + // table has to give them + it('gives the identifier of an account', async () => { + const aiService = createAiService([createAccount()]); + + const result = await aiService.getAccountsTable({ userId: 'user-id' }); + + expect(result).toContain('account-a-id'); + }); + it('marks an account which is excluded from the analysis', async () => { const aiService = createAiService([ createAccount({ isExcluded: true }), - createAccount({ name: 'Account B' }) + createAccount({ id: 'account-b-id', name: 'Account B' }) ]); const result = await aiService.getAccountsTable({ userId: 'user-id' }); @@ -132,7 +146,7 @@ describe('AiService', () => { const [rowOfAccountA, rowOfAccountB] = result .split('\n') .filter((line) => { - return line.startsWith('Account '); + return line.startsWith('account-'); }); expect(rowOfAccountA).toContain('true'); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 029fcc81b..758499c6e 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -30,9 +30,11 @@ export class AiService { | 'ALLOCATION_PERCENTAGE' | 'CURRENCY' | 'EXCLUDED_FROM_ANALYSIS' + | 'ID' | 'NAME' | 'PLATFORM'; } & ColumnDescriptor)[] = [ + { key: 'ID', name: 'Id' }, { key: 'NAME', name: 'Name' }, { key: 'CURRENCY', name: 'Currency' }, { key: 'PLATFORM', name: 'Platform' }, @@ -160,6 +162,7 @@ export class AiService { activitiesCount, allocationInPercentage, currency, + id, name: label, platform, tags @@ -183,6 +186,10 @@ export class AiService { row[name] = isAccountExcluded({ tags }).toString(); break; + case 'ID': + row[name] = id; + break; + case 'NAME': row[name] = label ?? ''; break; diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index f238b4890..f8261f5c0 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -8,6 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper' import { DATE_RANGES, DEFAULT_LANGUAGE_CODE, + MCP_MAX_ACCOUNTS, MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { scopes } from '@ghostfolio/common/scopes'; @@ -20,6 +21,14 @@ import { McpController, Tool } from '@rekog/mcp-nest'; import { z } from 'zod'; const GET_ACCOUNTS_PARAMETERS = z.object({ + accountIds: z + .array(z.string().min(1)) + .min(1) + .max(MCP_MAX_ACCOUNTS) + .optional() + .describe( + `The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}` + ), assetClasses: z .array(z.enum(AssetClass)) .min(1) @@ -104,9 +113,14 @@ export class GhostfolioMcpController { public async getAccounts( @Impersonation() { userId }: ImpersonationContext, @Payload() - { assetClasses, holding }: z.infer + { + accountIds, + assetClasses, + holding + }: z.infer ) { const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts: accountIds?.join(','), filterByAssetClasses: assetClasses?.join(','), filterByDataSource: holding?.dataSource, filterBySymbol: holding?.symbol diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 8946d986c..5e11f8ae1 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -277,6 +277,7 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED = export const MAX_TOP_HOLDINGS = 50; export const MCP_ENDPOINT = '/mcp'; +export const MCP_MAX_ACCOUNTS = 50; export const MCP_MAX_ACTIVITIES = 100; export const MCP_REALM = 'Ghostfolio';