Thomas Kaul 23 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 ## Unreleased
### Added
- Added a tool to update the cash balance of an account to the server of the Model Context Protocol (MCP) (experimental)
### Changed ### Changed
- Improved the colors of the muted texts and borders by enabling the dark mode of `bootstrap` - 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) ## 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 ### 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. - 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 - 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 ### Connect a client

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')) {

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

@ -15,7 +15,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 { createActivity } from './mcp.test-utils'; import { createActivity } from './mcp.test-utils';
@ -312,3 +313,47 @@ describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => {
).toBe('Apple'); ).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 SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
isValidAccountBalanceDate,
isValidCurrencyCode, isValidCurrencyCode,
isValidDateAfter1970 isValidDateAfter1970
} from '@ghostfolio/common/helper'; } 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' '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 { 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,69 @@ 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 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 { 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,42 @@ 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 at ${date}`
);
}
private getHoldingFilterParameters({ private getHoldingFilterParameters({
holding holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, '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); ).not.toContain(scopes.portfolioReadValues);
}); });
// The controller exposes the tool to import activities as the only tool // The controller exposes no tool for a further write scope, hence it stays
// which writes, hence a further write scope stays ineffective // ineffective
it('Gives the write scope to create an activity only', () => { it('Gives the write scopes to update an account and to create an activity only', () => {
expect( expect(
getScopesOfAccess({ getScopesOfAccess({
scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS], scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS],
@ -268,7 +268,7 @@ describe('Scopes', () => {
}).filter((scope) => { }).filter((scope) => {
return SCOPES_OF_WRITE_ACCESS.includes(scope as 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 // 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. * monetary values.
*/ */
const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = { 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), PRIVATE: Object.values(scopes),
PUBLIC: SCOPES_OF_PUBLIC_ACCESS 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