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() |
|||
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…
Reference in new issue