Browse Source

Merge 773a1301f8 into a057f1bdc5

pull/8178/merge
Thomas Kaul 8 hours ago
committed by GitHub
parent
commit
f4a0996af2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 4
      README.md
  3. 12
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  4. 24
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  5. 6
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  6. 47
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  7. 20
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  8. 83
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  9. 44
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  10. 8
      libs/common/src/lib/scopes.spec.ts
  11. 2
      libs/common/src/lib/scopes.ts
  12. 6
      prisma/migrations/20261014000000_added_account_update_scope_to_mcp_access/migration.sql

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added a tool to update the cash balance of an account to the server of the Model Context Protocol (MCP) (experimental)
### Changed
- Improved the colors of the muted texts and borders by enabling the dark mode of `bootstrap`

4
README.md

@ -399,7 +399,7 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
## Model Context Protocol (experimental)
The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio and import activities into it.
The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio, import activities into it and update the cash balances of your accounts.
### Prerequisites
@ -407,7 +407,7 @@ The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio
- Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin.
- Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token
An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities.
An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities and update the cash balances of your accounts.
### Connect a client

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

@ -245,7 +245,8 @@ describe('GhostfolioMcpController', () => {
'get-portfolio',
'get-watchlist',
'import-activities',
'search-asset-profiles'
'search-asset-profiles',
'update-account-balance'
]);
});
@ -287,7 +288,8 @@ describe('GhostfolioMcpController', () => {
'get-portfolio',
'get-watchlist',
'import-activities',
'search-asset-profiles'
'search-asset-profiles',
'update-account-balance'
]);
});
@ -348,6 +350,12 @@ describe('GhostfolioMcpController', () => {
).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
// that an unexpected exception does not expose internals
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_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
SEARCH_ASSET_PROFILES_PARAMETERS,
UPDATE_ACCOUNT_BALANCE_PARAMETERS
} from './mcp.schemas';
import { McpService } from './mcp.service';
@ -193,6 +194,27 @@ export class GhostfolioMcpController {
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[]) {
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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
@ -24,6 +26,8 @@ import { McpService } from './mcp.service';
controllers: [GhostfolioMcpController],
exports: [McpService],
imports: [
AccountBalanceModule,
AccountModule,
ApiModule,
ConfigurationModule,
ImportModule,
@ -40,7 +44,7 @@ import { McpService } from './mcp.service';
const { hostname } = new URL(configurationService.get('ROOT_URL'));
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')) {

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

@ -15,7 +15,8 @@ import {
GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
SEARCH_ASSET_PROFILES_PARAMETERS,
UPDATE_ACCOUNT_BALANCE_PARAMETERS
} from './mcp.schemas';
import { createActivity } from './mcp.test-utils';
@ -312,3 +313,47 @@ describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => {
).toBe('Apple');
});
});
describe('UPDATE_ACCOUNT_BALANCE_PARAMETERS', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-03T23:30:00.000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
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: '2026-09-03' })).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: '1969-12-31' })).toBe(false);
expect(parse({ date: '1970-01-01' })).toBe(false);
});
it('Refuses a date and time', () => {
expect(parse({ date: '2024-01-01T00:30:00+02:00' })).toBe(false);
});
it('Refuses a date in the future', () => {
expect(parse({ date: '2026-09-05' })).toBe(false);
});
});

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

@ -11,6 +11,7 @@ import {
SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config';
import {
isValidAccountBalanceDate,
isValidCurrencyCode,
isValidDateAfter1970
} from '@ghostfolio/common/helper';
@ -179,3 +180,22 @@ 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'
)
});
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.iso
.date({
abort: true,
error: 'The date must be an ISO 8601 date, for example 2024-01-01'
})
.refine(isValidDateAfter1970, {
error: 'The date must be after 1970-01-01'
})
.refine(isValidAccountBalanceDate, {
error: 'The date must not be in the future'
})
.describe('The date of the cash balance, as an ISO 8601 date')
});

83
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 { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -12,7 +15,10 @@ import {
} from '@ghostfolio/common/config';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
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 {
@ -30,6 +36,8 @@ describe('McpService', () => {
const userCurrency = 'USD';
const userId = 'user-id';
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let apiService: ApiService;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
@ -62,6 +70,12 @@ describe('McpService', () => {
})
} as unknown as ConfigurationService;
accountBalanceService = {
createOrUpdateAccountBalance: jest.fn()
} as unknown as AccountBalanceService;
accountService = { account: jest.fn() } as unknown as AccountService;
apiService = {
buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters)
} as unknown as ApiService;
@ -85,6 +99,8 @@ describe('McpService', () => {
userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService(
accountBalanceService,
accountService,
apiService,
configurationService,
importService,
@ -636,4 +652,69 @@ describe('McpService', () => {
).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 at 2024-01-01',
type: 'text'
}
]
});
expect(
accountBalanceService.createOrUpdateAccountBalance
).toHaveBeenCalledWith({
userId,
accountId: 'account-id',
balance: 1000,
date: '2024-01-01'
});
});
});
});

44
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 { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import {
transformDataSourceInRequest,
transformDataSourceInResponse
@ -21,12 +24,15 @@ import {
GET_ACTIVITIES_PARAMETERS,
GET_PERFORMANCE_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
SEARCH_ASSET_PROFILES_PARAMETERS,
UPDATE_ACCOUNT_BALANCE_PARAMETERS
} from './mcp.schemas';
@Injectable()
export class McpService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@ -243,6 +249,42 @@ export class McpService {
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 at ${date}`
);
}
private getHoldingFilterParameters({
holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {

8
libs/common/src/lib/scopes.spec.ts

@ -258,9 +258,9 @@ describe('Scopes', () => {
).not.toContain(scopes.portfolioReadValues);
});
// The controller exposes the tool to import activities as the only tool
// which writes, hence a further write scope stays ineffective
it('Gives the write scope to create an activity only', () => {
// The controller exposes no tool for a further write scope, hence it stays
// ineffective
it('Gives the write scopes to update an account and to create an activity only', () => {
expect(
getScopesOfAccess({
scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS],
@ -268,7 +268,7 @@ describe('Scopes', () => {
}).filter((scope) => {
return SCOPES_OF_WRITE_ACCESS.includes(scope as Scope);
})
).toEqual([scopes.activityCreate]);
).toEqual([scopes.accountUpdate, scopes.activityCreate]);
});
// The dialog narrows the access level to the scopes which the type

2
libs/common/src/lib/scopes.ts

@ -68,7 +68,7 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] =
* monetary values.
*/
const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
MCP: [...SCOPES_OF_READ_ACCESS, scopes.activityCreate],
MCP: [...SCOPES_OF_READ_ACCESS, scopes.accountUpdate, scopes.activityCreate],
PRIVATE: Object.values(scopes),
PUBLIC: SCOPES_OF_PUBLIC_ACCESS
};

6
prisma/migrations/20261014000000_added_account_update_scope_to_mcp_access/migration.sql

@ -0,0 +1,6 @@
-- Grant the scope to update an account to the existing accesses of the model context protocol which can create an activity
UPDATE "Access"
SET "scopes" = array_append("scopes", 'account:update')
WHERE "type" = 'MCP'
AND 'activity:create' = ANY("scopes")
AND NOT ('account:update' = ANY("scopes"));
Loading…
Cancel
Save