Browse Source

Add tool to update account balance to MCP server

pull/8178/head
Thomas Kaul 1 day ago
parent
commit
e4de2af7fe
  1. 12
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 24
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 6
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 38
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  5. 22
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  6. 80
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  7. 42
      apps/api/src/app/endpoints/mcp/mcp.service.ts

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

@ -245,7 +245,8 @@ describe('GhostfolioMcpController', () => {
'get-portfolio', 'get-portfolio',
'get-watchlist', 'get-watchlist',
'import-activities', 'import-activities',
'search-asset-profiles' 'search-asset-profiles',
'update-account-balance'
]); ]);
}); });
@ -287,7 +288,8 @@ describe('GhostfolioMcpController', () => {
'get-portfolio', 'get-portfolio',
'get-watchlist', 'get-watchlist',
'import-activities', 'import-activities',
'search-asset-profiles' 'search-asset-profiles',
'update-account-balance'
]); ]);
}); });
@ -348,6 +350,12 @@ describe('GhostfolioMcpController', () => {
).toEqual([scopes.activityCreate]); ).toEqual([scopes.activityCreate]);
}); });
it('Requires the scope to update an account for the tool to update the account balance', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'updateAccountBalance')
).toEqual([scopes.accountUpdate]);
});
// The tools have no try and catch, hence the filter is the only guarantee // The tools have no try and catch, hence the filter is the only guarantee
// that an unexpected exception does not expose internals // that an unexpected exception does not expose internals
it('Applies the filter of the exceptions of the tools', () => { it('Applies the filter of the exceptions of the tools', () => {

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

@ -18,7 +18,8 @@ import {
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS, GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS SEARCH_ASSET_PROFILES_PARAMETERS,
UPDATE_ACCOUNT_BALANCE_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
import { McpService } from './mcp.service'; import { McpService } from './mcp.service';
@ -193,6 +194,27 @@ export class GhostfolioMcpController {
return this.mcpService.searchAssetProfiles({ ...parameters, userId }); return this.mcpService.searchAssetProfiles({ ...parameters, userId });
} }
@RequiresScopeOfAccess(scopes.accountUpdate)
@Tool({
annotations: {
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
readOnlyHint: false,
title: 'Update account balance'
},
description:
'Sets the cash balance of an account at a date. The balance is in the currency of the account, which get-accounts gives. A cash balance of the account at the same date is replaced. The cash balance at the most recent date is the current cash balance of the account. A date in the future is refused.',
name: 'update-account-balance',
parameters: UPDATE_ACCOUNT_BALANCE_PARAMETERS
})
public async updateAccountBalance(
@Impersonation() { userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof UPDATE_ACCOUNT_BALANCE_PARAMETERS>
) {
return this.mcpService.updateAccountBalance({ ...parameters, userId });
}
private hasScopeToReadValues(impersonationScopes: string[]) { private hasScopeToReadValues(impersonationScopes: string[]) {
return hasScope(impersonationScopes, scopes.portfolioReadValues); return hasScope(impersonationScopes, scopes.portfolioReadValues);
} }

6
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -1,3 +1,5 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { ImportModule } from '@ghostfolio/api/app/import/import.module'; import { ImportModule } from '@ghostfolio/api/app/import/import.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
@ -24,6 +26,8 @@ import { McpService } from './mcp.service';
controllers: [GhostfolioMcpController], controllers: [GhostfolioMcpController],
exports: [McpService], exports: [McpService],
imports: [ imports: [
AccountBalanceModule,
AccountModule,
ApiModule, ApiModule,
ConfigurationModule, ConfigurationModule,
ImportModule, ImportModule,
@ -40,7 +44,7 @@ import { McpService } from './mcp.service';
const { hostname } = new URL(configurationService.get('ROOT_URL')); const { hostname } = new URL(configurationService.get('ROOT_URL'));
const instructions = [ const instructions = [
'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give the quantities and the monetary values only if the access has the permission "View" or "View and manage", otherwise no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage" or "View and manage".' 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access, import activities into the portfolio and update the cash balances of the accounts. They give the quantities and the monetary values only if the access has the permission "View" or "View and manage", otherwise no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities or to update a cash balance, the access needs the permission "Restricted view and manage" or "View and manage".'
]; ];
if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

38
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -7,15 +7,18 @@ import {
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
SYMBOL_MAXIMUM_LENGTH SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { addDays, format } from 'date-fns';
import { import {
GET_ACCOUNTS_PARAMETERS, GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS, GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS SEARCH_ASSET_PROFILES_PARAMETERS,
UPDATE_ACCOUNT_BALANCE_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
import { createActivity } from './mcp.test-utils'; import { createActivity } from './mcp.test-utils';
@ -312,3 +315,36 @@ describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => {
).toBe('Apple'); ).toBe('Apple');
}); });
}); });
describe('UPDATE_ACCOUNT_BALANCE_PARAMETERS', () => {
function parse(parameters: Record<string, unknown>) {
return UPDATE_ACCOUNT_BALANCE_PARAMETERS.safeParse({
accountId: 'account-id',
balance: 1000,
date: '2024-01-01',
...parameters
}).success;
}
it('Accepts a cash balance of today', () => {
expect(parse({ date: format(new Date(), DATE_FORMAT) })).toBe(true);
});
it('Accepts a negative cash balance', () => {
expect(parse({ balance: -1000 })).toBe(true);
});
it('Refuses an empty identifier of the account', () => {
expect(parse({ accountId: '' })).toBe(false);
});
it('Refuses a date at or before the epoch', () => {
expect(parse({ date: '0000-01-01' })).toBe(false);
});
it('Refuses a date in the future', () => {
expect(parse({ date: format(addDays(new Date(), 2), DATE_FORMAT) })).toBe(
false
);
});
});

22
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -11,11 +11,14 @@ import {
SYMBOL_MAXIMUM_LENGTH SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
isAccountBalanceInFuture,
isValidCurrencyCode, isValidCurrencyCode,
isValidDateAfter1970 isValidDateAfter1970,
resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { parseISO } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`; const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`;
@ -179,3 +182,20 @@ export const SEARCH_ASSET_PROFILES_PARAMETERS = z.object({
'The name, ticker symbol or ISIN of the financial asset, for example Apple, AAPL, Bitcoin or US0378331005' 'The name, ticker symbol or ISIN of the financial asset, for example Apple, AAPL, Bitcoin or US0378331005'
) )
}); });
export const UPDATE_ACCOUNT_BALANCE_PARAMETERS = z.object({
accountId: z.string().min(1).describe('The identifier of the account'),
balance: z
.number()
.describe('The cash balance of the account in the currency of the account'),
date: z
.string()
.refine(isValidDateAfter1970)
.refine(
(date) => {
return !isAccountBalanceInFuture({ date: resetHours(parseISO(date)) });
},
{ error: 'The date must not be in the future' }
)
.describe('The date of the cash balance, as an ISO 8601 date')
});

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

@ -1,7 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -12,7 +15,10 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { UserWithSettings } from '@ghostfolio/common/types'; import type {
AccountWithBalance,
UserWithSettings
} from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common'; import { HttpException } from '@nestjs/common';
import { import {
@ -30,6 +36,8 @@ describe('McpService', () => {
const userCurrency = 'USD'; const userCurrency = 'USD';
const userId = 'user-id'; const userId = 'user-id';
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let apiService: ApiService; let apiService: ApiService;
let configuration: Record<string, unknown>; let configuration: Record<string, unknown>;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
@ -62,6 +70,12 @@ describe('McpService', () => {
}) })
} as unknown as ConfigurationService; } as unknown as ConfigurationService;
accountBalanceService = {
createOrUpdateAccountBalance: jest.fn()
} as unknown as AccountBalanceService;
accountService = { account: jest.fn() } as unknown as AccountService;
apiService = { apiService = {
buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters) buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters)
} as unknown as ApiService; } as unknown as ApiService;
@ -85,6 +99,8 @@ describe('McpService', () => {
userService = { user: jest.fn() } as unknown as UserService; userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService( mcpService = new McpService(
accountBalanceService,
accountService,
apiService, apiService,
configurationService, configurationService,
importService, importService,
@ -636,4 +652,66 @@ describe('McpService', () => {
).rejects.toBe(error); ).rejects.toBe(error);
}); });
}); });
describe('updateAccountBalance', () => {
const parameters = {
userId,
accountId: 'account-id',
balance: 1000,
date: '2024-01-01'
};
it('Refuses a user without the permission to create an account balance', async () => {
setupUser([]);
await expect(mcpService.updateAccountBalance(parameters)).rejects.toThrow(
HttpException
);
expect(
accountBalanceService.createOrUpdateAccountBalance
).not.toHaveBeenCalled();
});
it('Refuses an account which does not belong to the user', async () => {
setupUser([permissions.createAccountBalance]);
jest.spyOn(accountService, 'account').mockResolvedValue(null);
await expect(mcpService.updateAccountBalance(parameters)).rejects.toThrow(
CallerFacingError
);
expect(accountService.account).toHaveBeenCalledWith({
id_userId: { userId, id: 'account-id' }
});
expect(
accountBalanceService.createOrUpdateAccountBalance
).not.toHaveBeenCalled();
});
it('Updates the cash balance of the account of the user', async () => {
setupUser([permissions.createAccountBalance]);
jest
.spyOn(accountService, 'account')
.mockResolvedValue({ userId, id: 'account-id' } as AccountWithBalance);
expect(await mcpService.updateAccountBalance(parameters)).toEqual({
content: [
{ text: 'Updated the cash balance of the account', type: 'text' }
]
});
expect(
accountBalanceService.createOrUpdateAccountBalance
).toHaveBeenCalledWith({
userId,
accountId: 'account-id',
balance: 1000,
date: '2024-01-01'
});
});
});
}); });

42
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -1,6 +1,9 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { import {
transformDataSourceInRequest, transformDataSourceInRequest,
transformDataSourceInResponse transformDataSourceInResponse
@ -21,12 +24,15 @@ import {
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS, GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS, IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS SEARCH_ASSET_PROFILES_PARAMETERS,
UPDATE_ACCOUNT_BALANCE_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
@Injectable() @Injectable()
export class McpService { export class McpService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
@ -243,6 +249,40 @@ export class McpService {
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2));
} }
public async updateAccountBalance({
accountId,
balance,
date,
userId
}: z.infer<typeof UPDATE_ACCOUNT_BALANCE_PARAMETERS> & { userId: string }) {
await this.getUserWithPermission({
userId,
permission: permissions.createAccountBalance
});
const account = await this.accountService.account({
id_userId: {
userId,
id: accountId
}
});
if (!account) {
throw new CallerFacingError(
'The account does not exist. Use get-accounts to get the identifier of the account.'
);
}
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
date,
accountId: account.id,
userId: account.userId
});
return this.getTextResult('Updated the cash balance of the account');
}
private getHoldingFilterParameters({ private getHoldingFilterParameters({
holding holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) { }: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {

Loading…
Cancel
Save