Browse Source

Add MCP tool to import activities

pull/7766/head
Thomas Kaul 2 months ago
parent
commit
486ddfa6fc
  1. 80
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 27
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 39
      apps/api/src/app/import/import.service.ts
  4. 13
      apps/api/src/services/data-provider/data-provider.service.ts
  5. 10
      libs/common/src/lib/helper.spec.ts
  6. 2
      libs/common/src/lib/helper.ts

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

@ -12,7 +12,7 @@ import type {
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common'; import { HttpException, Logger } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices'; import { RpcException } from '@nestjs/microservices';
import { DataSource, Type as ActivityType } from '@prisma/client'; import { DataSource, Type as ActivityType } from '@prisma/client';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@ -50,6 +50,7 @@ function createActivity(overrides: Record<string, unknown> = {}) {
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
const impersonation = { userId: 'user-id' } as ImpersonationContext; const impersonation = { userId: 'user-id' } as ImpersonationContext;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let controller: GhostfolioMcpController; let controller: GhostfolioMcpController;
let importService: ImportService; let importService: ImportService;
@ -62,8 +63,15 @@ describe('GhostfolioMcpController', () => {
} }
beforeEach(() => { beforeEach(() => {
configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false
};
configurationService = { configurationService = {
get: jest.fn().mockReturnValue([]) get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService; } as unknown as ConfigurationService;
importService = { importService = {
@ -81,6 +89,10 @@ describe('GhostfolioMcpController', () => {
); );
}); });
afterEach(() => {
jest.restoreAllMocks();
});
describe('Import activities', () => { describe('Import activities', () => {
it('Requires the scope to create an activity', () => { it('Requires the scope to create an activity', () => {
expect( expect(
@ -127,9 +139,8 @@ describe('GhostfolioMcpController', () => {
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]); setupUser([permissions.createActivity]);
jest configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
.spyOn(configurationService, 'get') configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
.mockReturnValue([DataSource.YAHOO]);
await controller.importActivities(impersonation, { await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
@ -144,6 +155,25 @@ describe('GhostfolioMcpController', () => {
); );
}); });
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 })
]
})
);
});
it('Passes on the message of a validation only', async () => { it('Passes on the message of a validation only', async () => {
setupUser([permissions.createActivity]); setupUser([permissions.createActivity]);
@ -162,14 +192,18 @@ describe('GhostfolioMcpController', () => {
); );
}); });
it('Hides the message of an unexpected error', async () => { it('Hides the message of an unexpected error and writes it to the log', async () => {
setupUser([permissions.createActivity]); setupUser([permissions.createActivity]);
jest const error = new Error(
.spyOn(importService, 'import') 'Unique constraint failed on the fields: (dataSource)'
.mockRejectedValue( );
new Error('Unique constraint failed on the fields: (dataSource)')
); const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
jest.spyOn(importService, 'import').mockRejectedValue(error);
await expect( await expect(
controller.importActivities(impersonation, { controller.importActivities(impersonation, {
@ -178,6 +212,30 @@ describe('GhostfolioMcpController', () => {
).rejects.toThrow( ).rejects.toThrow(
new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR))
); );
expect(logError).toHaveBeenCalledWith(error);
});
it('Does not write the message of a validation to the log', async () => {
setupUser([permissions.createActivity]);
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.accountId ("X") is not valid')
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity({ accountId: 'X' })]
})
).rejects.toThrow(RpcException);
expect(logError).not.toHaveBeenCalled();
}); });
}); });

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

@ -25,7 +25,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { HttpException, UseFilters } from '@nestjs/common'; import { HttpException, Logger, UseFilters } from '@nestjs/common';
import { Payload, RpcException } from '@nestjs/microservices'; import { Payload, RpcException } from '@nestjs/microservices';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { McpController, Tool } from '@rekog/mcp-nest'; import { McpController, Tool } from '@rekog/mcp-nest';
@ -142,6 +142,8 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
@McpController() @McpController()
@UseFilters(McpToolExceptionFilter) @UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController { export class GhostfolioMcpController {
private readonly logger = new Logger(GhostfolioMcpController.name);
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
@ -278,7 +280,7 @@ export class GhostfolioMcpController {
readOnlyHint: false, readOnlyHint: false,
title: 'Import activities' title: 'Import activities'
}, },
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`,
name: 'import-activities', name: 'import-activities',
parameters: IMPORT_ACTIVITIES_PARAMETERS parameters: IMPORT_ACTIVITIES_PARAMETERS
}) })
@ -296,8 +298,10 @@ export class GhostfolioMcpController {
} }
const ghostfolioDataSources = this.configurationService.get( const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' 'ENABLE_FEATURE_SUBSCRIPTION'
); )
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => { const activitiesDto = activities.map((activity) => {
return { return {
@ -322,12 +326,17 @@ export class GhostfolioMcpController {
}); });
} catch (error) { } catch (error) {
// The message of a validation names the activity which is not valid and // The message of a validation names the activity which is not valid and
// is written for the caller, hence it is passed on. Every other error // is written for the caller, hence it is passed on
// can carry internals of the application. if (error instanceof ImportValidationError) {
throw new RpcException(error.message);
}
// Every other message can carry internals of the application, hence it
// is written to the log and the reason phrase is passed on instead
this.logger.error(error);
throw new RpcException( throw new RpcException(
error instanceof ImportValidationError getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)
? error.message
: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)
); );
} }

39
apps/api/src/app/import/import.service.ts

@ -43,7 +43,7 @@ import {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { isSameSecond, parseISO } from 'date-fns'; import { isSameSecond, parseISO } from 'date-fns';
@ -741,22 +741,12 @@ export class ImportService {
} }
} }
let assetProfiles: { const assetProfiles = await this.dataProviderService.validateActivities({
[assetProfileIdentifier: string]: Partial<SymbolProfile>; activitiesDto,
}; assetProfilesWithMarketDataDto,
maxActivitiesToImport,
try { subscription: user.subscription
assetProfiles = await this.dataProviderService.validateActivities({ });
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
subscription: user.subscription
});
} catch (error) {
// The messages of the validation name the activity which is not valid
// and are written for the caller
throw new ImportValidationError(error.message);
}
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto, activitiesDto,
@ -787,6 +777,21 @@ export class ImportService {
}); });
} }
// Validate the accounts before any activity is created, since an account
// which does not belong to the user is dropped without a notice otherwise
for (const [index, { accountId }] of activitiesDto.entries()) {
if (
accountId &&
!accounts.some(({ id }) => {
return id === accountId;
})
) {
throw new ImportValidationError(
`activities.${index}.accountId ("${accountId}") is not valid`
);
}
}
const tags = (await this.tagService.getTagsForUser(user.id)).map( const tags = (await this.tagService.getTagsForUser(user.id)).map(
({ id, name }) => { ({ id, name }) => {
return { id, name }; return { id, name };

13
apps/api/src/services/data-provider/data-provider.service.ts

@ -1,3 +1,4 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto'; import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
@ -226,7 +227,9 @@ export class DataProviderService implements OnModuleInit {
subscription: UserWithSettings['subscription']; subscription: UserWithSettings['subscription'];
}) { }) {
if (activitiesDto?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new ImportValidationError(
`Too many activities (${maxActivitiesToImport} at most)`
);
} }
const assetProfiles: { const assetProfiles: {
@ -250,7 +253,7 @@ export class DataProviderService implements OnModuleInit {
}); });
if (!dataSources.includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new ImportValidationError(
`${activityPath}.dataSource ("${dataSource}") is not valid` `${activityPath}.dataSource ("${dataSource}") is not valid`
); );
} }
@ -259,7 +262,7 @@ export class DataProviderService implements OnModuleInit {
dataSource !== DataSource.MANUAL && dataSource !== DataSource.MANUAL &&
isValidCustomAssetProfileSymbol(symbol) isValidCustomAssetProfileSymbol(symbol)
) { ) {
throw new Error( throw new ImportValidationError(
`${activityPath}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")`
); );
} }
@ -271,7 +274,7 @@ export class DataProviderService implements OnModuleInit {
const dataProvider = this.getDataProvider(DataSource[dataSource]); const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (dataProvider.getDataProviderInfo().isPremium) { if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error( throw new ImportValidationError(
`${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium` `${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium`
); );
} }
@ -328,7 +331,7 @@ export class DataProviderService implements OnModuleInit {
} catch {} } catch {}
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new ImportValidationError(
`${activityPath}.symbol ("${symbol}") cannot be resolved by the data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") cannot be resolved by the data source ("${maskedDataSource}")`
); );
} }

10
libs/common/src/lib/helper.spec.ts

@ -440,6 +440,16 @@ describe('Helper', () => {
expect(isValidDateAfter1970('1970-01-01T00:00:00.000Z')).toEqual(false); expect(isValidDateAfter1970('1970-01-01T00:00:00.000Z')).toEqual(false);
}); });
// A date without a time is read in UTC, hence the result is the same in
// every time zone of the server
it('Date of 1970 without a time', () => {
expect(isValidDateAfter1970('1970-01-01')).toEqual(false);
});
it('Date after 1970 without a time', () => {
expect(isValidDateAfter1970('1970-01-02')).toEqual(true);
});
it('Date with an expanded year', () => { it('Date with an expanded year', () => {
expect(isValidDateAfter1970('+010000-01-01')).toEqual(true); expect(isValidDateAfter1970('+010000-01-01')).toEqual(true);
}); });

2
libs/common/src/lib/helper.ts

@ -686,7 +686,7 @@ export function isValidCustomAssetProfileSymbol(aSymbol: string) {
} }
export function isValidDateAfter1970(aDate: string) { export function isValidDateAfter1970(aDate: string) {
const date = parseISO(aDate); const date = parseISO(aDate, { in: utc });
return isValid(date) && isAfter(date, new Date(0)); return isValid(date) && isAfter(date, new Date(0));
} }

Loading…
Cancel
Save