mirror of https://github.com/ghostfolio/ghostfolio
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
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 }] };
|
|
}
|
|
}
|
|
|