mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
10 changed files with 357 additions and 227 deletions
@ -0,0 +1,115 @@ |
|||
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; |
|||
import { |
|||
DATE_RANGES, |
|||
MCP_MAX_ACCOUNTS, |
|||
MCP_MAX_ACTIVITIES |
|||
} from '@ghostfolio/common/config'; |
|||
import { |
|||
isValidCurrencyCode, |
|||
isValidDateAfter1970 |
|||
} from '@ghostfolio/common/helper'; |
|||
|
|||
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; |
|||
import { z } from 'zod'; |
|||
|
|||
const HOLDING_PARAMETER = z.object({ |
|||
dataSource: z |
|||
.enum(DataSource) |
|||
.describe('The data source of the asset profile'), |
|||
symbol: z.string().describe('The symbol of the asset profile') |
|||
}); |
|||
|
|||
export const GET_ACCOUNTS_PARAMETERS = z.object({ |
|||
accountIds: z |
|||
.array(z.string().min(1)) |
|||
.min(1) |
|||
.max(MCP_MAX_ACCOUNTS) |
|||
.optional() |
|||
.describe( |
|||
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}` |
|||
), |
|||
assetClasses: z |
|||
.array(z.enum(AssetClass)) |
|||
.min(1) |
|||
.optional() |
|||
.describe('The asset classes of the accounts to get'), |
|||
holding: HOLDING_PARAMETER.optional().describe( |
|||
'The asset profile of the accounts to get' |
|||
) |
|||
}); |
|||
|
|||
export const GET_ACTIVITIES_PARAMETERS = z.object({ |
|||
activityTypes: z |
|||
.array(z.enum(ActivityType)) |
|||
.min(1) |
|||
.optional() |
|||
.describe('The types of the activities to get'), |
|||
assetClasses: z |
|||
.array(z.enum(AssetClass)) |
|||
.min(1) |
|||
.optional() |
|||
.describe('The asset classes of the activities to get'), |
|||
holding: HOLDING_PARAMETER.optional().describe( |
|||
'The asset profile of the activities to get' |
|||
), |
|||
range: z |
|||
.string() |
|||
.regex(DATE_RANGE_PATTERN) |
|||
.optional() |
|||
.describe( |
|||
`The date range of the activities to get, either ${DATE_RANGES.join( |
|||
', ' |
|||
)} or a calendar year like 2024` |
|||
), |
|||
skip: z |
|||
.number() |
|||
.int() |
|||
.min(0) |
|||
.optional() |
|||
.describe('The number of activities to skip'), |
|||
take: z |
|||
.number() |
|||
.int() |
|||
.min(1) |
|||
.max(MCP_MAX_ACTIVITIES) |
|||
.default(MCP_MAX_ACTIVITIES) |
|||
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) |
|||
}); |
|||
|
|||
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ |
|||
activities: z |
|||
.array( |
|||
z.object({ |
|||
accountId: z |
|||
.string() |
|||
.min(1) |
|||
.optional() |
|||
.describe('The identifier of the account of the activity'), |
|||
comment: z.string().optional().describe('The comment of the activity'), |
|||
currency: z |
|||
.string() |
|||
.refine(isValidCurrencyCode) |
|||
.describe( |
|||
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' |
|||
), |
|||
dataSource: z |
|||
.enum(DataSource) |
|||
.optional() |
|||
.describe('The data source of the asset profile'), |
|||
date: z |
|||
.string() |
|||
.refine(isValidDateAfter1970) |
|||
.describe( |
|||
'The date of the activity, as an ISO 8601 date or date and time' |
|||
), |
|||
fee: z.number().min(0).describe('The fee of the activity'), |
|||
quantity: z.number().min(0).describe('The quantity of the activity'), |
|||
symbol: z.string().min(1).describe('The symbol of the asset profile'), |
|||
type: z.enum(ActivityType).describe('The type of the activity'), |
|||
unitPrice: z.number().min(0).describe('The unit price of the activity') |
|||
}) |
|||
) |
|||
.min(1) |
|||
.max(MCP_MAX_ACTIVITIES) |
|||
.describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`) |
|||
}); |
|||
@ -0,0 +1,8 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class McpService { |
|||
public getTextResult(text: string) { |
|||
return { content: [{ text, type: 'text' as const }] }; |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
/** |
|||
* An error whose message is written for the caller. A filter passes such a |
|||
* message on, while it hides the message of every other error, because that |
|||
* message can carry internals of the application. |
|||
*/ |
|||
export class CallerFacingError extends Error { |
|||
public constructor(message: string) { |
|||
super(message); |
|||
|
|||
this.name = 'CallerFacingError'; |
|||
} |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; |
|||
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; |
|||
|
|||
import { ForbiddenException, Logger } from '@nestjs/common'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
import { firstValueFrom } from 'rxjs'; |
|||
|
|||
import { McpToolExceptionFilter } from './mcp-tool-exception.filter'; |
|||
|
|||
describe('McpToolExceptionFilter', () => { |
|||
let filter: McpToolExceptionFilter; |
|||
let logError: jest.SpyInstance; |
|||
|
|||
async function getErrorOfException(exception: unknown) { |
|||
try { |
|||
await firstValueFrom(filter.catch(exception)); |
|||
} catch (error) { |
|||
return error; |
|||
} |
|||
|
|||
throw new Error('The filter gave no error'); |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
filter = new McpToolExceptionFilter(); |
|||
|
|||
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
it('Passes on the message of an error which is written for the caller', async () => { |
|||
const exception = new ImportValidationError( |
|||
'activities.0.symbol ("X") is not valid' |
|||
); |
|||
|
|||
expect(await getErrorOfException(exception)).toEqual({ |
|||
message: 'activities.0.symbol ("X") is not valid', |
|||
status: 'error' |
|||
}); |
|||
|
|||
expect(logError).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('Hides the message of an unexpected error and writes it to the log', async () => { |
|||
const exception = new Error( |
|||
'Unique constraint failed on the fields: (dataSource)' |
|||
); |
|||
|
|||
expect(await getErrorOfException(exception)).toEqual({ |
|||
message: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
status: 'error' |
|||
}); |
|||
|
|||
expect(logError).toHaveBeenCalledWith(exception); |
|||
}); |
|||
|
|||
// An access without the scope of a tool causes a refused call at each
|
|||
// attempt, which would fill the log
|
|||
it('Gives the reason phrase of the status of an HttpException and writes no log', async () => { |
|||
expect(await getErrorOfException(new ForbiddenException())).toEqual({ |
|||
message: getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
status: 'error' |
|||
}); |
|||
|
|||
expect(logError).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('Gives the reason phrase of a service which is not available if a snapshot cannot be computed', async () => { |
|||
const exception = new PortfolioSnapshotComputationError( |
|||
'The snapshot cannot be computed' |
|||
); |
|||
|
|||
expect(await getErrorOfException(exception)).toEqual({ |
|||
message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
|||
status: 'error' |
|||
}); |
|||
|
|||
expect(logError).toHaveBeenCalledWith(exception); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue