You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

209 lines
6.2 KiB

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 { 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))
.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()
@UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController {
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)
@Tool({
annotations: {
openWorldHint: false,
readOnlyHint: true,
title: 'Get portfolio'
},
description: `Gives the holdings of the portfolio with these columns: ${AiService.getHoldingsTableColumnNames().join(
', '
)}.`,
name: 'get-portfolio'
})
public async getPortfolio(
@Impersonation() { filters, userId, userSettings }: ImpersonationContext
) {
const prompt = await this.aiService.getPrompt({
filters,
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio',
userCurrency: userSettings.baseCurrency
});
return { content: [{ text: prompt, type: 'text' as const }] };
}
}