Browse Source

Move the logic of the tools to the McpService

pull/7789/head
Thomas Kaul 1 month ago
parent
commit
7679d30d8d
  1. 286
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 134
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 62
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  4. 186
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 177
      apps/api/src/app/endpoints/mcp/mcp.service.ts

286
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -1,30 +1,15 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { Activity } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Scope, scopes } from '@ghostfolio/common/scopes';
import type {
ImpersonationContext,
UserWithSettings
} from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common';
import {
EXCEPTION_FILTERS_METADATA,
GUARDS_METADATA
} from '@nestjs/common/constants';
import { DataSource, Type as ActivityType } from '@prisma/client';
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas';
import { McpService } from './mcp.service';
// The controller reads the columns of the tables from the AiService, which
// imports two packages which ship as an ECMAScript module only, which Jest
@ -62,250 +47,53 @@ function getToolMethodNames() {
);
}
function createActivity(overrides: Record<string, unknown> = {}) {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}
describe('GhostfolioMcpController', () => {
const impersonation = { userId: 'user-id' } as ImpersonationContext;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
let controller: GhostfolioMcpController;
let importService: ImportService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => {
configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false
};
// A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope
it('Requires a scope of access for each tool', () => {
const toolMethodNames = getToolMethodNames();
configurationService = {
get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
expect(toolMethodNames.length).toBeGreaterThan(0);
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
userService = { user: jest.fn() } as unknown as UserService;
controller = new GhostfolioMcpController(
undefined,
undefined,
configurationService,
importService,
new McpService(),
userService
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Tools', () => {
// A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope
it('Requires a scope of access for each tool', () => {
const toolMethodNames = getToolMethodNames();
expect(toolMethodNames.length).toBeGreaterThan(0);
const toolMethodNamesWithoutScope = toolMethodNames.filter(
(methodName) => {
return !getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName)
?.length;
}
);
expect(toolMethodNamesWithoutScope).toEqual([]);
const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => {
return !getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName)
?.length;
});
// The decorator RequiresScope sets the same metadata as the decorator
// RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of
// an access cannot pass, hence the guards tell the two decorators apart
it('Applies the guard of the access to each tool', () => {
const toolMethodNames = getToolMethodNames();
expect(toolMethodNames.length).toBeGreaterThan(0);
const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter(
(methodName) => {
return !getMetadataOfMethod<unknown[]>(
GUARDS_METADATA,
methodName
)?.includes(AccessGuard);
}
);
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
});
// The tools have no try and catch, hence the filter is the only guarantee
// that an unexpected exception does not expose internals
it('Applies the filter of the exceptions of the tools', () => {
expect(
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController)
).toEqual([McpToolExceptionFilter]);
});
expect(toolMethodNamesWithoutScope).toEqual([]);
});
describe('Import activities', () => {
it('Requires the scope to create an activity', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')
).toEqual([scopes.activityCreate]);
});
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(HttpException);
expect(importService.import).not.toHaveBeenCalled();
});
it('Gives the number of the imported and of the skipped activities', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockResolvedValue([{ id: 'activity-id' } as Activity]);
expect(
await controller.importActivities(impersonation, {
activities: [createActivity(), createActivity({ quantity: 2 })]
})
).toEqual({
content: [
{
text: 'Imported activities: 1\nSkipped duplicate activities: 1',
type: 'text'
}
]
});
});
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO })
]
})
);
});
// The McpToolExceptionFilter maps the error, hence the tool passes it on
it('Passes on an error of the import', async () => {
setupUser([permissions.createActivity]);
const error = new ImportValidationError(
'activities.0.symbol ("X") is not valid'
);
jest.spyOn(importService, 'import').mockRejectedValue(error);
// The decorator RequiresScope sets the same metadata as the decorator
// RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of
// an access cannot pass, hence the guards tell the two decorators apart
it('Applies the guard of the access to each tool', () => {
const toolMethodNames = getToolMethodNames();
expect(toolMethodNames.length).toBeGreaterThan(0);
const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter(
(methodName) => {
return !getMetadataOfMethod<unknown[]>(
GUARDS_METADATA,
methodName
)?.includes(AccessGuard);
}
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toBe(error);
});
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
});
describe('Parameters of the tool to import activities', () => {
function parse(activities: unknown[]) {
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success;
}
it('Refuses a currency in lower case', () => {
expect(parse([createActivity({ currency: 'usd' })])).toBe(false);
});
it('Accepts a currency in upper case', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});
it('Refuses an empty symbol', () => {
expect(parse([createActivity({ symbol: '' })])).toBe(false);
});
it('Refuses an empty identifier of an account', () => {
expect(parse([createActivity({ accountId: '' })])).toBe(false);
});
it('Removes a tag, because the tool takes no tag', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [createActivity({ tags: ['tag-id'] })]
}).activities[0]
).not.toHaveProperty('tags');
});
it('Requires the scope to create an activity for the tool to import activities', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')
).toEqual([scopes.activityCreate]);
});
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => {
expect(
parse(
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivity();
})
)
).toBe(false);
});
// The tools have no try and catch, hence the filter is the only guarantee
// that an unexpected exception does not expose internals
it('Applies the filter of the exceptions of the tools', () => {
expect(
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController)
).toEqual([McpToolExceptionFilter]);
});
});

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

@ -1,25 +1,14 @@
import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types';
import { HttpException, UseFilters } from '@nestjs/common';
import { UseFilters } from '@nestjs/common';
import { Payload } from '@nestjs/microservices';
import { McpController, Tool } from '@rekog/mcp-nest';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod';
import {
@ -32,14 +21,7 @@ import { McpService } from './mcp.service';
@McpController()
@UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController {
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly mcpService: McpService,
private readonly userService: UserService
) {}
public constructor(private readonly mcpService: McpService) {}
@RequiresScopeOfAccess(scopes.accountRead)
@Tool({
@ -56,23 +38,9 @@ export class GhostfolioMcpController {
})
public async getAccounts(
@Impersonation() { userId }: ImpersonationContext,
@Payload()
{
accountIds,
assetClasses,
holding
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
@Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds,
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getAccountsTable({ filters, userId });
return this.mcpService.getTextResult(table);
return this.mcpService.getAccounts({ ...parameters, userId });
}
@RequiresScopeOfAccess(scopes.activityRead)
@ -89,45 +57,14 @@ export class GhostfolioMcpController {
parameters: GET_ACTIVITIES_PARAMETERS
})
public async getActivities(
@Impersonation()
{ userId, userSettings }: ImpersonationContext,
@Payload()
{
activityTypes,
assetClasses,
holding,
range,
skip,
take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
@Impersonation() { userId, userSettings }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) {
let endDate: Date | undefined;
let startDate: Date | undefined;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
take,
return this.mcpService.getActivities({
...parameters,
userId,
types: activityTypes,
userCurrency: userSettings.baseCurrency
});
return this.mcpService.getTextResult(table);
}
@RequiresScopeOfAccess(scopes.portfolioRead)
@ -145,14 +82,10 @@ export class GhostfolioMcpController {
public async getPortfolio(
@Impersonation() { userId, userSettings }: ImpersonationContext
) {
const prompt = await this.aiService.getPrompt({
return this.mcpService.getPortfolio({
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio',
userCurrency: userSettings.baseCurrency
});
return this.mcpService.getTextResult(prompt);
}
/**
@ -175,51 +108,8 @@ export class GhostfolioMcpController {
})
public async importActivities(
@Impersonation() { userId }: ImpersonationContext,
@Payload() { activities }: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS>
@Payload() parameters: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS>
) {
const user = await this.userService.user({ id: userId });
if (!hasPermission(user?.permissions, permissions.createActivity)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
});
// The filter passes on the message of a CallerFacingError, which is
// written for the caller, and hides the message of every other error
const importedActivities = await this.importService.import({
activitiesDto,
user,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
const text = [
`Imported activities: ${importedActivities.length}`,
`Skipped duplicate activities: ${
activities.length - importedActivities.length
}`
].join('\n');
return this.mcpService.getTextResult(text);
return this.mcpService.importActivities({ ...parameters, userId });
}
}

62
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -0,0 +1,62 @@
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { Type as ActivityType } from '@prisma/client';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas';
function createActivity(overrides: Record<string, unknown> = {}) {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}
describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
function parse(activities: unknown[]) {
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success;
}
it('Refuses a currency in lower case', () => {
expect(parse([createActivity({ currency: 'usd' })])).toBe(false);
});
it('Accepts a currency in upper case', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});
it('Refuses an empty symbol', () => {
expect(parse([createActivity({ symbol: '' })])).toBe(false);
});
it('Refuses an empty identifier of an account', () => {
expect(parse([createActivity({ accountId: '' })])).toBe(false);
});
it('Removes a tag, because the tool takes no tag', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [createActivity({ tags: ['tag-id'] })]
}).activities[0]
).not.toHaveProperty('tags');
});
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => {
expect(
parse(
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivity();
})
)
).toBe(false);
});
});

186
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -0,0 +1,186 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Activity } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { UserWithSettings } from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common';
import { DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas';
import { McpService } from './mcp.service';
// The service reads the tables from the AiService, which 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() };
});
type ActivityToImport = z.infer<
typeof IMPORT_ACTIVITIES_PARAMETERS
>['activities'][number];
function createActivity(
overrides: Partial<ActivityToImport> = {}
): ActivityToImport {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}
describe('McpService', () => {
const userId = 'user-id';
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
let importService: ImportService;
let mcpService: McpService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => {
configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false
};
configurationService = {
get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService(
undefined,
undefined,
configurationService,
importService,
userService
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('importActivities', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
mcpService.importActivities({
userId,
activities: [createActivity()]
})
).rejects.toThrow(HttpException);
expect(importService.import).not.toHaveBeenCalled();
});
it('Gives the number of the imported and of the skipped activities', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockResolvedValue([{ id: 'activity-id' } as Activity]);
expect(
await mcpService.importActivities({
userId,
activities: [createActivity(), createActivity({ quantity: 2 })]
})
).toEqual({
content: [
{
text: 'Imported activities: 1\nSkipped duplicate activities: 1',
type: 'text'
}
]
});
});
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.importActivities({
userId,
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
await mcpService.importActivities({
userId,
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO })
]
})
);
});
// The McpToolExceptionFilter maps the error, hence the tool passes it on
it('Passes on an error of the import', async () => {
setupUser([permissions.createActivity]);
const error = new ImportValidationError(
'activities.0.symbol ("X") is not valid'
);
jest.spyOn(importService, 'import').mockRejectedValue(error);
await expect(
mcpService.importActivities({
userId,
activities: [createActivity()]
})
).rejects.toBe(error);
});
});
});

177
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -1,8 +1,181 @@
import { Injectable } from '@nestjs/common';
import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod';
import {
GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS
} from './mcp.schemas';
@Injectable()
export class McpService {
public getTextResult(text: string) {
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly userService: UserService
) {}
public async getAccounts({
accountIds,
assetClasses,
holding,
userId
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { userId: string }) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds,
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getAccountsTable({ filters, userId });
return this.getTextResult(table);
}
public async getActivities({
activityTypes,
assetClasses,
holding,
range,
skip,
take,
userCurrency,
userId
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & {
userCurrency: string;
userId: string;
}) {
let endDate: Date | undefined;
let startDate: Date | undefined;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
take,
userCurrency,
userId,
types: activityTypes
});
return this.getTextResult(table);
}
public async getPortfolio({
userCurrency,
userId
}: {
userCurrency: string;
userId: string;
}) {
const prompt = await this.aiService.getPrompt({
userCurrency,
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio'
});
return this.getTextResult(prompt);
}
public async importActivities({
activities,
userId
}: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS> & { userId: string }) {
const user = await this.getUserWithPermission({
userId,
permission: permissions.createActivity
});
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
});
// The filter passes on the message of a CallerFacingError, which is
// written for the caller, and hides the message of every other error
const importedActivities = await this.importService.import({
activitiesDto,
user,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
const text = [
`Imported activities: ${importedActivities.length}`,
`Skipped duplicate activities: ${
activities.length - importedActivities.length
}`
].join('\n');
return this.getTextResult(text);
}
private getTextResult(text: string) {
return { content: [{ text, type: 'text' as const }] };
}
/**
* Gives the user of the access, if the role of the user has the permission.
* The scope of the access is evaluated separately by the ScopeGuard, hence a
* tool which changes data has to call this.
*/
private async getUserWithPermission({
permission,
userId
}: {
permission: string;
userId: string;
}) {
const user = await this.userService.user({ id: userId });
if (!hasPermission(user?.permissions, permission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return user;
}
}

Loading…
Cancel
Save