Browse Source

Feature/add MCP tool to get activities (#7742)

* Add MCP tool to get activities

* Update changelog
pull/7743/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
e2a6048d1d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 274
      apps/api/src/app/endpoints/ai/ai.service.ts
  3. 173
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  4. 5
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  5. 1
      libs/common/src/lib/config.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 _Restricted view and manage_ permission to the access to share the portfolio (experimental) - Added the _Restricted view and manage_ permission to the access to share the portfolio (experimental)
- Added a tool to get the activities of the portfolio to the server of the Model Context Protocol (MCP) (experimental)
### Changed ### Changed

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

@ -1,3 +1,4 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
@ -12,13 +13,58 @@ import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { AssetClass, AssetSubClass } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { ColumnDescriptor } from 'tablemark'; import type { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
export class AiService { export class AiService {
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACCOUNT'
| 'CURRENCY'
| 'DATE'
| 'FEE'
| 'NAME'
| 'QUANTITY'
| 'SYMBOL'
| 'TYPE'
| 'UNIT_PRICE'
| 'VALUE';
requiresScopeToReadValues?: boolean;
} & 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' }
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({
key: key:
| 'ACTIVITIES_COUNT' | 'ACTIVITIES_COUNT'
@ -45,18 +91,43 @@ export class AiService {
]; ];
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public static getActivitiesTableColumnNames({
withValues
}: {
withValues: boolean;
}) {
return AiService.getActivitiesTableColumnDefinitions({ withValues }).map(
({ name }) => {
return name;
}
);
}
public static getHoldingsTableColumnNames() { public static getHoldingsTableColumnNames() {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name; return name;
}); });
} }
private static getActivitiesTableColumnDefinitions({
withValues
}: {
withValues: boolean;
}) {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter(
({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
}
);
}
public async generateText({ public async generateText({
prompt, prompt,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
@ -83,6 +154,135 @@ export class AiService {
}); });
} }
public async getActivitiesTable({
endDate,
filters,
skip = 0,
startDate,
take,
types,
userCurrency,
userId,
withValues
}: {
endDate?: Date;
filters?: Filter[];
skip?: number;
startDate?: Date;
take: number;
types?: ActivityType[];
userCurrency: string;
userId: string;
withValues: boolean;
}) {
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
startDate,
take,
types,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'desc',
withExcludedAccountsAndActivities: true
});
const activitiesTableColumnDefinitions =
AiService.getActivitiesTableColumnDefinitions({ withValues });
const activitiesTableRows = activities.map(
({
account,
assetProfile,
currency,
date,
fee,
quantity,
type,
unitPrice,
value
}) => {
return activitiesTableColumnDefinitions.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACCOUNT':
row[name] = account?.name ?? '';
break;
case 'CURRENCY':
row[name] = currency ?? assetProfile.currency;
break;
case 'DATE':
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;
case 'TYPE':
row[name] = type;
break;
case 'UNIT_PRICE':
row[name] = unitPrice.toString();
break;
case 'VALUE':
row[name] = value.toString();
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const activitiesSection = [
'## Activities',
'',
this.getActivitiesSummary({
count,
skip,
numberOfActivities: activities.length
})
];
if (activitiesTableRows.length > 0) {
activitiesSection.push(
'',
await this.getMarkdownTable({
columnDefinitions: activitiesTableColumnDefinitions,
rows: activitiesTableRows
})
);
}
return activitiesSection.join('\n');
}
public async getPrompt({ public async getPrompt({
filters, filters,
languageCode, languageCode,
@ -101,11 +301,6 @@ export class AiService {
userId userId
}); });
const holdingsTableColumns: ColumnDescriptor[] =
AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => {
return { name, align: align ?? 'left' };
});
const assetClassTranslations = this.getEnumTranslations({ const assetClassTranslations = this.getEnumTranslations({
languageCode, languageCode,
id: 'assetClass', id: 'assetClass',
@ -184,18 +379,12 @@ export class AiService {
} }
); );
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
const holdingsSection = [ const holdingsSection = [
'## Holdings', '## Holdings',
'', '',
tablemark(holdingsTableRows, { await this.getMarkdownTable({
columns: holdingsTableColumns columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
rows: holdingsTableRows
}) })
].join('\n'); ].join('\n');
@ -218,6 +407,40 @@ export class AiService {
].join('\n'); ].join('\n');
} }
private getActivitiesSummary({
count,
numberOfActivities,
skip
}: {
count: number;
numberOfActivities: number;
skip: number;
}) {
if (count === 0) {
return 'No activities found.';
}
if (numberOfActivities === 0) {
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`;
}
if (numberOfActivities === count) {
return `Showing all ${count} activities, the most recent first.`;
}
const lastActivity = skip + numberOfActivities;
const summary = `Showing the activities ${
skip + 1
} to ${lastActivity} of ${count}, the most recent first.`;
if (lastActivity === count) {
return summary;
}
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
}
private getEnumTranslations<T extends string>({ private getEnumTranslations<T extends string>({
id, id,
languageCode, languageCode,
@ -240,4 +463,25 @@ export class AiService {
{} as Record<T, string> {} as Record<T, string>
); );
} }
private async getMarkdownTable({
columnDefinitions,
rows
}: {
columnDefinitions: readonly ColumnDescriptor[];
rows: Record<string, string>[];
}) {
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
})
});
}
} }

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

@ -1,18 +1,185 @@
import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator';
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { scopes } from '@ghostfolio/common/scopes'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATE_RANGES,
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { hasScope, 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';
import { Payload } from '@nestjs/microservices';
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';
const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
.min(1)
.optional()
.describe('The types of the activities to get'),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the activities 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 activities to get'),
range: z
.string()
.regex(DATE_RANGE_PATTERN)
.optional()
.describe(
`The date range of the activities to get, either ${DATE_RANGES.join(
', '
)} or a calendar year like 2024`
),
skip: z
.number()
.int()
.min(0)
.optional()
.describe('The number of activities to skip'),
take: z
.number()
.int()
.min(1)
.max(MCP_MAX_ACTIVITIES)
.optional()
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
@McpController() @McpController()
@UseFilters(McpToolExceptionFilter) @UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController { export class GhostfolioMcpController {
public constructor(private readonly aiService: AiService) {} public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService
) {}
@RequiresScopeOfAccess(scopes.activityRead)
@Tool({
annotations: {
openWorldHint: false,
readOnlyHint: true,
title: 'Get activities'
},
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames(
{ 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.`,
name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS
})
public async getActivities(
@Impersonation()
{
filters,
scopes: scopesOfAccess,
userId,
userSettings
}: ImpersonationContext,
@Payload()
{
activityTypes,
assetClasses,
holding,
range,
skip,
take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) {
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filtersOfAccess = filters ?? [];
const filtersOfTool = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
// A tool must never widen the access, hence a filter of the tool which
// the access does not permit gives no activity
const filtersOfToolOutsideAccess = filtersOfTool.filter(({ id, type }) => {
const filtersOfAccessOfType = filtersOfAccess.filter((filter) => {
return filter.type === type;
});
return (
filtersOfAccessOfType.length > 0 &&
!filtersOfAccessOfType.some((filter) => {
return filter.id === id;
})
);
});
if (filtersOfToolOutsideAccess.length > 0) {
const valuesOutsideAccess = filtersOfToolOutsideAccess
.map(({ id }) => {
return id;
})
.join(', ');
return {
content: [
{
text: `No activities found. The access does not permit these values of the parameters: ${valuesOutsideAccess}.`,
type: 'text' as const
}
]
};
}
const typesOfFiltersOfTool = new Set(
filtersOfTool.map(({ type }) => {
return type;
})
);
// The filters of a type are combined with a logical or, hence a filter of
// the tool replaces the filters of the access of the same type instead of
// joining them
const filtersOfAccessOutsideTool = filtersOfAccess.filter(({ type }) => {
return !typesOfFiltersOfTool.has(type);
});
const table = await this.aiService.getActivitiesTable({
endDate,
skip,
startDate,
userId,
filters: [...filtersOfAccessOutsideTool, ...filtersOfTool],
take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes,
userCurrency: userSettings.baseCurrency,
withValues: hasScope(scopesOfAccess, scopes.portfolioReadValues)
});
return { content: [{ text: table, type: 'text' as const }] };
}
@RequiresScopeOfAccess(scopes.portfolioRead) @RequiresScopeOfAccess(scopes.portfolioRead)
@Tool({ @Tool({

5
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -1,5 +1,6 @@
import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
@ -15,7 +16,7 @@ import { GhostfolioMcpController } from './mcp.controller';
@Module({ @Module({
controllers: [GhostfolioMcpController], controllers: [GhostfolioMcpController],
imports: [AiModule, ConfigurationModule], imports: [AiModule, ApiModule, ConfigurationModule],
providers: [ providers: [
{ {
inject: [ConfigurationService], inject: [ConfigurationService],
@ -25,7 +26,7 @@ import { GhostfolioMcpController } from './mcp.controller';
return new McpStrategy({ return new McpStrategy({
instructions: instructions:
'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access. They give no monetary value.', 'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access. They give no quantity and no monetary value (except the unit price of an activity).',
name: 'ghostfolio', name: 'ghostfolio',
title: 'Ghostfolio', title: 'Ghostfolio',
transports: [ transports: [

1
libs/common/src/lib/config.ts

@ -269,6 +269,7 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED =
export const MAX_TOP_HOLDINGS = 50; export const MAX_TOP_HOLDINGS = 50;
export const MCP_ENDPOINT = '/mcp'; export const MCP_ENDPOINT = '/mcp';
export const MCP_MAX_ACTIVITIES = 100;
export const MCP_REALM = 'Ghostfolio'; export const MCP_REALM = 'Ghostfolio';
export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100; export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100;

Loading…
Cancel
Save