diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts index 2c84df6d02..2e89f5fbb0 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/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 = {}) { - 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; - 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(REQUIRES_SCOPE_KEY, methodName) - ?.length; - } - ); - - expect(toolMethodNamesWithoutScope).toEqual([]); + const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => { + return !getMetadataOfMethod(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( - 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(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( + 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(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]); }); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 420acab3e1..486c359c08 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/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 + @Payload() parameters: z.infer ) { - 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 + @Impersonation() { userId, userSettings }: ImpersonationContext, + @Payload() parameters: z.infer ) { - 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 + @Payload() parameters: z.infer ) { - 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 }); } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts new file mode 100644 index 0000000000..98f49e0067 --- /dev/null +++ b/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 = {}) { + 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); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts new file mode 100644 index 0000000000..e7085a8aa7 --- /dev/null +++ b/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 { + 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; + 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); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 2da6003b49..32e7b2090b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/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 & { 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 & { + 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 & { 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; + } }