mirror of https://github.com/ghostfolio/ghostfolio
5 changed files with 472 additions and 373 deletions
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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() |
@Injectable() |
||||
export class McpService { |
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 }] }; |
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…
Reference in new issue