From d6d891c94290091a7434c35e988f5a5d89c86118 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:13:43 +0200 Subject: [PATCH] Task/improve instructions of MCP server (#8263) * Improve instructions of MCP server * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/endpoints/mcp/mcp.module.ts | 26 +++---- .../src/helper/bearer-token.helper.spec.ts | 71 ++++++++++++++++++- apps/api/src/helper/bearer-token.helper.ts | 18 +++++ 4 files changed, 103 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb5b04598..cf8a06d8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the style of the close button in the dialog footer component - Improved the style of the close button in the dialog header component - Restricted the asset endpoint (`GET api/v1/asset/:dataSource/:symbol`) to authenticated users +- Refined the MCP server instructions based on the user’s subscription (experimental) - Improved the language localization for German (`de`) - Upgraded `prettier` from version `3.9.9` to `3.9.10` diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index efa8917404..d36bef4e9d 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -3,7 +3,10 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { environment } from '@ghostfolio/api/environments/environment'; -import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; +import { + getMcpUserOfBearerToken, + hasLimitsOfBasicSubscriptionOfBearerToken +} from '@ghostfolio/api/helper/bearer-token.helper'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -43,20 +46,19 @@ import { McpService } from './mcp.service'; useFactory: (configurationService: ConfigurationService) => { 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, 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".' - ]; + const instructions = + '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')) { - instructions.push( - `With the Basic subscription, the tool get-performance does not give these columns: ${PortfolioTableService.getAssetPerformanceInBaseCurrencyColumnNames().join( - ', ' - )}.` - ); - } + const instructionsOfBasicSubscription = `${instructions} The user who granted the access has the Basic subscription, hence the tool get-performance does not give these columns: ${PortfolioTableService.getAssetPerformanceInBaseCurrencyColumnNames().join( + ', ' + )}.`; return new McpStrategy({ - instructions: instructions.join(' '), + instructions: ({ request }) => { + return hasLimitsOfBasicSubscriptionOfBearerToken(request) + ? instructionsOfBasicSubscription + : instructions; + }, name: 'ghostfolio', resolveUser: getMcpUserOfBearerToken, title: 'Ghostfolio', diff --git a/apps/api/src/helper/bearer-token.helper.spec.ts b/apps/api/src/helper/bearer-token.helper.spec.ts index e2f48ff5bb..ad3eb66374 100644 --- a/apps/api/src/helper/bearer-token.helper.spec.ts +++ b/apps/api/src/helper/bearer-token.helper.spec.ts @@ -1,6 +1,10 @@ +import { SubscriptionType } from '@ghostfolio/common/enums'; import { scopes } from '@ghostfolio/common/scopes'; -import { getMcpUserOfBearerToken } from './bearer-token.helper'; +import { + getMcpUserOfBearerToken, + hasLimitsOfBasicSubscriptionOfBearerToken +} from './bearer-token.helper'; describe('getMcpUserOfBearerToken', () => { it('should give the scopes of an active access', () => { @@ -29,3 +33,68 @@ describe('getMcpUserOfBearerToken', () => { expect(getMcpUserOfBearerToken({})).toBeUndefined(); }); }); + +describe('hasLimitsOfBasicSubscriptionOfBearerToken', () => { + it('should apply to an active access with the Basic subscription which reads the values', () => { + expect( + hasLimitsOfBasicSubscriptionOfBearerToken({ + impersonationOfBearerToken: { + isActive: true, + scopes: [scopes.portfolioRead, scopes.portfolioReadValues], + userSubscription: { type: SubscriptionType.Basic } + } + }) + ).toBe(true); + }); + + it('should not apply to an access with the Basic subscription which does not read the values', () => { + expect( + hasLimitsOfBasicSubscriptionOfBearerToken({ + impersonationOfBearerToken: { + isActive: true, + scopes: [scopes.portfolioRead], + userSubscription: { type: SubscriptionType.Basic } + } + }) + ).toBe(false); + }); + + it('should not apply to an access with the Premium subscription', () => { + expect( + hasLimitsOfBasicSubscriptionOfBearerToken({ + impersonationOfBearerToken: { + isActive: true, + scopes: [scopes.portfolioRead, scopes.portfolioReadValues], + userSubscription: { type: SubscriptionType.Premium } + } + }) + ).toBe(false); + }); + + it('should not apply to an access without a subscription', () => { + expect( + hasLimitsOfBasicSubscriptionOfBearerToken({ + impersonationOfBearerToken: { + isActive: true, + scopes: [scopes.portfolioRead, scopes.portfolioReadValues] + } + }) + ).toBe(false); + }); + + it('should not apply to an inactive access', () => { + expect( + hasLimitsOfBasicSubscriptionOfBearerToken({ + impersonationOfBearerToken: { + isActive: false, + scopes: [scopes.portfolioRead, scopes.portfolioReadValues], + userSubscription: { type: SubscriptionType.Basic } + } + }) + ).toBe(false); + }); + + it('should not apply without an access', () => { + expect(hasLimitsOfBasicSubscriptionOfBearerToken({})).toBe(false); + }); +}); diff --git a/apps/api/src/helper/bearer-token.helper.ts b/apps/api/src/helper/bearer-token.helper.ts index 47be1e69b6..2c7f9cd65e 100644 --- a/apps/api/src/helper/bearer-token.helper.ts +++ b/apps/api/src/helper/bearer-token.helper.ts @@ -1,3 +1,5 @@ +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { RequestWithUser } from '@ghostfolio/common/types'; const PREFIX_OF_BEARER_TOKEN = 'bearer '; @@ -45,3 +47,19 @@ export function getMcpUserOfBearerToken(request: unknown) { ? { scopes: impersonationOfBearerToken.scopes } : undefined; } + +/** + * Tells if the limits of the Basic subscription apply to the active access + * which the bearer token carries. The limits concern only value columns, hence + * they apply only to an access which reads the monetary values. + */ +export function hasLimitsOfBasicSubscriptionOfBearerToken(request: unknown) { + const impersonationOfBearerToken = + getActiveImpersonationOfBearerToken(request); + + return ( + impersonationOfBearerToken?.userSubscription?.type === + SubscriptionType.Basic && + hasScope(impersonationOfBearerToken.scopes, scopes.portfolioReadValues) + ); +}