Browse Source

Add MCP tool to get activities

pull/7742/head
Thomas Kaul 1 day ago
parent
commit
6b0ec1ba61
  1. 260
      apps/api/src/app/endpoints/ai/ai.service.ts
  2. 141
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 3
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 1
      libs/common/src/lib/config.ts

260
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { 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 { format } from 'date-fns';
import type { ColumnDescriptor } from 'tablemark';
@Injectable()
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: ({
key:
| 'ACTIVITIES_COUNT'
@ -45,12 +91,19 @@ export class AiService {
];
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
) {}
public static getActivitiesTableColumnNames() {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public static getHoldingsTableColumnNames() {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
@ -83,6 +136,139 @@ 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.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter(
({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
}
);
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({
filters,
languageCode,
@ -101,11 +287,6 @@ export class AiService {
userId
});
const holdingsTableColumns: ColumnDescriptor[] =
AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => {
return { name, align: align ?? 'left' };
});
const assetClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetClass',
@ -184,18 +365,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 = [
'## Holdings',
'',
tablemark(holdingsTableRows, {
columns: holdingsTableColumns
await this.getMarkdownTable({
columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
rows: holdingsTableRows
})
].join('\n');
@ -218,6 +393,40 @@ export class AiService {
].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>({
id,
languageCode,
@ -240,4 +449,25 @@ export class AiService {
{} 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' };
})
});
}
}

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

@ -1,18 +1,153 @@
import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.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 { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
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 { 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 { z } from 'zod';
const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
.optional()
.describe('The types of the activities to get'),
assetClasses: z
.array(z.enum(AssetClass))
.optional()
.describe('The asset classes of the activities to get'),
dataSource: z
.enum(DataSource)
.optional()
.describe(
'The data source of the asset profile, which only takes effect together with the symbol'
),
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'),
symbol: z
.string()
.optional()
.describe(
'The symbol of the asset profile, which only takes effect together with the data source'
),
take: z
.number()
.int()
.min(1)
.max(MCP_MAX_ACTIVITIES)
.optional()
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
@McpController()
@UseFilters(McpToolExceptionFilter)
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().join(
', '
)}. The columns with a monetary value are omitted if the access does not grant 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,
dataSource,
range,
skip,
symbol,
take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) {
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filtersOfAccess = filters ?? [];
const typesOfFiltersOfAccess = new Set(
filtersOfAccess.map(({ type }) => {
return type;
})
);
// A filter of the tool is dropped if the access already restricts its
// type, because the filters of a type are combined with a logical or and
// a tool must never widen the access
const filtersOfTool = this.apiService
.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: dataSource,
filterBySymbol: symbol
})
.filter(({ type }) => {
return !typesOfFiltersOfAccess.has(type);
});
const table = await this.aiService.getActivitiesTable({
endDate,
skip,
startDate,
userId,
filters: [...filtersOfAccess, ...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)
@Tool({

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

@ -1,5 +1,6 @@
import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module';
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { MCP_ENDPOINT } from '@ghostfolio/common/config';
@ -15,7 +16,7 @@ import { GhostfolioMcpController } from './mcp.controller';
@Module({
controllers: [GhostfolioMcpController],
imports: [AiModule, ConfigurationModule],
imports: [AiModule, ApiModule, ConfigurationModule],
providers: [
{
inject: [ConfigurationService],

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 MCP_ENDPOINT = '/mcp';
export const MCP_MAX_ACTIVITIES = 100;
export const MCP_REALM = 'Ghostfolio';
export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100;

Loading…
Cancel
Save