Browse Source

Feature/add MCP tool to get accounts (#7744)

* Add MCP tool to get accounts

* Update changelog
pull/7757/head^2
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
c48fb4bd67
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 150
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  3. 192
      apps/api/src/app/endpoints/ai/ai.service.ts
  4. 59
      apps/api/src/app/endpoints/mcp/mcp.controller.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the support for the filters of the assistant to the accounts page - 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` - Extended the `GET api/v1/account` endpoint by the filters `accounts`, `assetClasses` and `tags`
### Changed ### Changed

150
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<string, string>[];
}): Promise<string>;
}
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.');
});
});
});

192
apps/api/src/app/endpoints/ai/ai.service.ts

@ -7,7 +7,7 @@ import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config'; } 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 { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types'; import type { AiPromptMode } from '@ghostfolio/common/types';
@ -24,44 +24,43 @@ import type { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
export class AiService { 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: ({ private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({
key: key:
| 'ACCOUNT' | 'ACCOUNT'
| 'CURRENCY' | 'CURRENCY'
| 'DATE' | 'DATE'
| 'FEE'
| 'NAME' | 'NAME'
| 'QUANTITY'
| 'SYMBOL' | 'SYMBOL'
| 'TYPE' | 'TYPE'
| 'UNIT_PRICE' | 'UNIT_PRICE';
| 'VALUE';
requiresScopeToReadValues?: boolean;
} & ColumnDescriptor)[] = [ } & ColumnDescriptor)[] = [
{ key: 'DATE', name: 'Date' }, { key: 'DATE', name: 'Date' },
{ key: 'TYPE', name: 'Type' }, { key: 'TYPE', name: 'Type' },
{ key: 'NAME', name: 'Name' }, { key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' }, { key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' }, { key: 'CURRENCY', name: 'Currency' },
{
align: 'right',
key: 'QUANTITY',
name: 'Quantity',
requiresScopeToReadValues: true
},
{ align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, { 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' } { key: 'ACCOUNT', name: 'Account' }
]; ];
@ -98,34 +97,22 @@ export class AiService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public static getActivitiesTableColumnNames({ public static getAccountsTableColumnNames() {
withValues return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
}: { return name;
withValues: boolean; });
}) {
return AiService.getActivitiesTableColumnDefinitions({ withValues }).map(
({ name }) => {
return name;
}
);
} }
public static getHoldingsTableColumnNames() { public static getActivitiesTableColumnNames() {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name; return name;
}); });
} }
private static getActivitiesTableColumnDefinitions({ public static getHoldingsTableColumnNames() {
withValues return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
}: { return name;
withValues: boolean; });
}) {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter(
({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
}
);
} }
public async generateText({ 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<string, string>
);
}
);
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({ public async getActivitiesTable({
endDate, endDate,
filters, filters,
@ -162,8 +227,7 @@ export class AiService {
take, take,
types, types,
userCurrency, userCurrency,
userId, userId
withValues
}: { }: {
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
@ -173,7 +237,6 @@ export class AiService {
types?: ActivityType[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withValues: boolean;
}) { }) {
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
@ -190,22 +253,9 @@ export class AiService {
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
const activitiesTableColumnDefinitions =
AiService.getActivitiesTableColumnDefinitions({ withValues });
const activitiesTableRows = activities.map( const activitiesTableRows = activities.map(
({ ({ account, assetProfile, currency, date, type, unitPrice }) => {
account, return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce(
assetProfile,
currency,
date,
fee,
quantity,
type,
unitPrice,
value
}) => {
return activitiesTableColumnDefinitions.reduce(
(row, { key, name }) => { (row, { key, name }) => {
switch (key) { switch (key) {
case 'ACCOUNT': case 'ACCOUNT':
@ -220,18 +270,10 @@ export class AiService {
row[name] = format(date, DATE_FORMAT); row[name] = format(date, DATE_FORMAT);
break; break;
case 'FEE':
row[name] = fee.toString();
break;
case 'NAME': case 'NAME':
row[name] = assetProfile.name ?? ''; row[name] = assetProfile.name ?? '';
break; break;
case 'QUANTITY':
row[name] = quantity.toString();
break;
case 'SYMBOL': case 'SYMBOL':
row[name] = assetProfile.symbol; row[name] = assetProfile.symbol;
break; break;
@ -244,10 +286,6 @@ export class AiService {
row[name] = unitPrice.toString(); row[name] = unitPrice.toString();
break; break;
case 'VALUE':
row[name] = value.toString();
break;
default: default:
row[name] = ''; row[name] = '';
break; break;
@ -274,7 +312,7 @@ export class AiService {
activitiesSection.push( activitiesSection.push(
'', '',
await this.getMarkdownTable({ await this.getMarkdownTable({
columnDefinitions: activitiesTableColumnDefinitions, columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
rows: activitiesTableRows rows: activitiesTableRows
}) })
); );

59
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -10,7 +10,7 @@ import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config'; } 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 type { ImpersonationContext } from '@ghostfolio/common/types';
import { UseFilters } from '@nestjs/common'; 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 { McpController, Tool } from '@rekog/mcp-nest';
import { z } from 'zod'; 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({ const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z activityTypes: z
.array(z.enum(ActivityType)) .array(z.enum(ActivityType))
@ -71,6 +88,35 @@ export class GhostfolioMcpController {
private readonly apiService: ApiService 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<typeof GET_ACCOUNTS_PARAMETERS>
) {
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) @RequiresScopeOfAccess(scopes.activityRead)
@Tool({ @Tool({
annotations: { annotations: {
@ -78,17 +124,15 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get activities' title: 'Get activities'
}, },
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames( description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().join(
{ 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.`, )}. 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', name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS parameters: GET_ACTIVITIES_PARAMETERS
}) })
public async getActivities( public async getActivities(
@Impersonation() @Impersonation()
{ scopes: scopesOfAccess, userId, userSettings }: ImpersonationContext, { userId, userSettings }: ImpersonationContext,
@Payload() @Payload()
{ {
activityTypes, activityTypes,
@ -122,8 +166,7 @@ export class GhostfolioMcpController {
userId, userId,
take: take ?? MCP_MAX_ACTIVITIES, take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes, types: activityTypes,
userCurrency: userSettings.baseCurrency, userCurrency: userSettings.baseCurrency
withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues)
}); });
return { content: [{ text: table, type: 'text' as const }] }; return { content: [{ text: table, type: 'text' as const }] };

Loading…
Cancel
Save