From 0fc152cc5a49834a725b660c82258d4b0658d9d1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:13:29 +0200 Subject: [PATCH] Task/filter MCP tools by scopes of access (#7935) * Update @rekog/mcp-nest to version 2.0.7 * Filter MCP tools by scopes of access * Update changelog --- CHANGELOG.md | 2 + .../app/endpoints/mcp/mcp.controller.spec.ts | 105 +++++++++++++++++- .../src/app/endpoints/mcp/mcp.controller.ts | 8 +- apps/api/src/app/endpoints/mcp/mcp.module.ts | 4 +- .../requires-scope-of-access.decorator.ts | 11 +- apps/api/src/guards/access.guard.ts | 8 +- .../src/helper/bearer-token.helper.spec.ts | 31 ++++++ apps/api/src/helper/bearer-token.helper.ts | 29 +++++ package-lock.json | 80 ++++++++++--- package.json | 2 +- 10 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/helper/bearer-token.helper.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d69d280038..f4efde2ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the server of the Model Context Protocol (MCP) to list only the tools covered by the scopes of the access (experimental) - Refreshed the cryptocurrencies list - Improved the language localization for Catalan (`ca`) +- Upgraded `@rekog/mcp-nest` from version `2.0.2` to `2.0.7` - Upgraded `bull-board` from version `9.9.0` to `9.10.1` - Upgraded `zod` from version `4.5.4` to `4.6.5` diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts index f6e1d04383..4fcb92a9be 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -1,13 +1,26 @@ import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; -import { Scope, scopes } from '@ghostfolio/common/scopes'; +import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; +import { + getScopesOfAccess, + getScopesOfAccessLevel, + Scope, + scopes +} from '@ghostfolio/common/scopes'; import { EXCEPTION_FILTERS_METADATA, GUARDS_METADATA } from '@nestjs/common/constants'; -import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest'; +import { + AccessMatchMode, + MCP_SCOPES_MATCH_METADATA_KEY, + MCP_SCOPES_METADATA_KEY, + MCP_TOOL_METADATA_KEY, + ToolAuthorizationService, + ToolMetadata +} from '@rekog/mcp-nest'; import { GhostfolioMcpController } from './mcp.controller'; @@ -35,6 +48,41 @@ function getToolMethodNames() { ); } +/** + * Gives the names of the tools which the transport lists for the request. The + * metadata of each tool is read as the transport reads it. + */ +function getNamesOfListedTools(request: unknown) { + const toolAuthorizationService = new ToolAuthorizationService(); + const user = getMcpUserOfBearerToken(request); + + return getToolMethodNames() + .map((methodName) => { + return { + metadata: { + ...getMetadataOfMethod( + MCP_TOOL_METADATA_KEY, + methodName + ), + requiredScopes: getMetadataOfMethod( + MCP_SCOPES_METADATA_KEY, + methodName + ), + requiredScopesMatch: getMetadataOfMethod( + MCP_SCOPES_MATCH_METADATA_KEY, + methodName + ) + } + }; + }) + .filter((tool) => { + return toolAuthorizationService.canAccessTool(user, tool); + }) + .map(({ metadata: { name } }) => { + return name; + }); +} + describe('GhostfolioMcpController', () => { // A tool without the decorator of the scope would be open to every access, // hence a new tool has to declare its scope @@ -71,6 +119,59 @@ describe('GhostfolioMcpController', () => { expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); }); + it('Lists only the tools to read for an access with the permission "Restricted view"', () => { + expect( + getNamesOfListedTools({ + impersonationOfBearerToken: { + isActive: true, + scopes: getScopesOfAccess({ + scopes: getScopesOfAccessLevel('READ_RESTRICTED'), + type: 'MCP' + }) + } + }) + ).toEqual([ + 'get-accounts', + 'get-activities', + 'get-portfolio', + 'get-watchlist' + ]); + }); + + it('Lists every tool for an access with the permission "Restricted view and manage"', () => { + expect( + getNamesOfListedTools({ + impersonationOfBearerToken: { + isActive: true, + scopes: getScopesOfAccess({ + scopes: getScopesOfAccessLevel( + 'CREATE_READ_RESTRICTED_UPDATE_DELETE' + ), + type: 'MCP' + }) + } + }) + ).toEqual([ + 'get-accounts', + 'get-activities', + 'get-portfolio', + 'get-watchlist', + 'import-activities', + 'search-asset-profiles' + ]); + }); + + it('Lists no tool for an inactive access', () => { + expect( + getNamesOfListedTools({ + impersonationOfBearerToken: { + isActive: false, + scopes: getScopesOfAccessLevel('CREATE_READ_RESTRICTED_UPDATE_DELETE') + } + }) + ).toEqual([]); + }); + it('Requires the scope to create an activity for the tool to import activities', () => { expect( getMetadataOfMethod(REQUIRES_SCOPE_KEY, 'importActivities') diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index dee145015f..802567ad35 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -101,12 +101,6 @@ export class GhostfolioMcpController { return this.mcpService.getWatchlist({ userId }); } - /** - * The transport gives the tool to every client, because it filters the list - * of the tools by the scopes of request.user, which a request of an access - * never has. The guard refuses the call itself, hence the description names - * the permission which the access needs. - */ @RequiresScopeOfAccess(scopes.activityCreate) @Tool({ annotations: { @@ -115,7 +109,7 @@ export class GhostfolioMcpController { readOnlyHint: false, title: 'Import activities' }, - description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, + description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, name: 'import-activities', parameters: IMPORT_ACTIVITIES_PARAMETERS }) diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index be81137f54..1da7a06823 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -2,6 +2,7 @@ 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'; import { environment } from '@ghostfolio/api/environments/environment'; +import { getMcpUserOfBearerToken } 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'; @@ -38,8 +39,9 @@ import { McpService } from './mcp.service'; return new McpStrategy({ 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 no quantity and no monetary value (except the unit price of an activity).', + '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 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".', name: 'ghostfolio', + resolveUser: getMcpUserOfBearerToken, title: 'Ghostfolio', transports: [ new StreamableHttpTransport({ diff --git a/apps/api/src/decorators/requires-scope-of-access.decorator.ts b/apps/api/src/decorators/requires-scope-of-access.decorator.ts index b76f1441c1..f0d35effcb 100644 --- a/apps/api/src/decorators/requires-scope-of-access.decorator.ts +++ b/apps/api/src/decorators/requires-scope-of-access.decorator.ts @@ -4,15 +4,24 @@ import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; import { Scope } from '@ghostfolio/common/scopes'; import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; +import { ToolScopes } from '@rekog/mcp-nest'; /** * Marks a handler which requires the given scopes of an access, but no * authenticated user. The access itself is the credential, hence a client of * the model context protocol can use it. + * + * The same scopes are declared to the transport of the model context + * protocol, which lists a tool only for an access whose scopes cover it. The + * transport refuses the call of a tool which it does not list with a protocol + * error before the guards and the filter of the exceptions run. */ -export function RequiresScopeOfAccess(...requiredScopes: Scope[]) { +export function RequiresScopeOfAccess(scope: Scope, ...otherScopes: Scope[]) { + const requiredScopes = [scope, ...otherScopes]; + return applyDecorators( SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), + ToolScopes(requiredScopes), UseGuards(AccessGuard, ScopeGuard) ); } diff --git a/apps/api/src/guards/access.guard.ts b/apps/api/src/guards/access.guard.ts index 87a1f24692..f57c5a5b85 100644 --- a/apps/api/src/guards/access.guard.ts +++ b/apps/api/src/guards/access.guard.ts @@ -1,3 +1,4 @@ +import { getActiveImpersonationOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -28,14 +29,17 @@ export class AccessGuard implements CanActivate { public canActivate(context: ExecutionContext) { const request = getRequest(context); - if (!request?.impersonationOfBearerToken?.isActive) { + const impersonationOfBearerToken = + getActiveImpersonationOfBearerToken(request); + + if (!impersonationOfBearerToken) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN ); } - request.impersonation = request.impersonationOfBearerToken; + request.impersonation = impersonationOfBearerToken; return true; } diff --git a/apps/api/src/helper/bearer-token.helper.spec.ts b/apps/api/src/helper/bearer-token.helper.spec.ts new file mode 100644 index 0000000000..e2f48ff5bb --- /dev/null +++ b/apps/api/src/helper/bearer-token.helper.spec.ts @@ -0,0 +1,31 @@ +import { scopes } from '@ghostfolio/common/scopes'; + +import { getMcpUserOfBearerToken } from './bearer-token.helper'; + +describe('getMcpUserOfBearerToken', () => { + it('should give the scopes of an active access', () => { + expect( + getMcpUserOfBearerToken({ + impersonationOfBearerToken: { + isActive: true, + scopes: [scopes.portfolioRead] + } + }) + ).toEqual({ scopes: [scopes.portfolioRead] }); + }); + + it('should give no user for an inactive access', () => { + expect( + getMcpUserOfBearerToken({ + impersonationOfBearerToken: { + isActive: false, + scopes: [scopes.portfolioRead] + } + }) + ).toBeUndefined(); + }); + + it('should give no user without an access', () => { + expect(getMcpUserOfBearerToken({})).toBeUndefined(); + }); +}); diff --git a/apps/api/src/helper/bearer-token.helper.ts b/apps/api/src/helper/bearer-token.helper.ts index 5641016f5b..47be1e69b6 100644 --- a/apps/api/src/helper/bearer-token.helper.ts +++ b/apps/api/src/helper/bearer-token.helper.ts @@ -1,3 +1,5 @@ +import type { RequestWithUser } from '@ghostfolio/common/types'; + const PREFIX_OF_BEARER_TOKEN = 'bearer '; /** @@ -16,3 +18,30 @@ export function getAccessIdOfBearerToken(authorization?: string) { ? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined : undefined; } + +/** + * Gives the context of the access which the authorization middleware of the + * model context protocol resolved from the bearer token, but only while the + * access is active + */ +export function getActiveImpersonationOfBearerToken( + request?: Pick +) { + return request?.impersonationOfBearerToken?.isActive + ? request.impersonationOfBearerToken + : undefined; +} + +/** + * Gives the user which the transport of the model context protocol evaluates + * to list and to call the tools. It carries the scopes of the active access, + * hence the transport lists only the tools which the access covers. + */ +export function getMcpUserOfBearerToken(request: unknown) { + const impersonationOfBearerToken = + getActiveImpersonationOfBearerToken(request); + + return impersonationOfBearerToken + ? { scopes: impersonationOfBearerToken.scopes } + : undefined; +} diff --git a/package-lock.json b/package-lock.json index 9ed3b35966..9469e56421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "@openrouter/ai-sdk-provider": "3.0.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", - "@rekog/mcp-nest": "2.0.2", + "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", "ai": "7.0.37", @@ -13614,14 +13614,13 @@ } }, "node_modules/@rekog/mcp-nest": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.2.tgz", - "integrity": "sha512-kuye6wmRk5Gw4KZSCSXd83cWznRw4eFbvNUXsd8+1dYcS4uWKVVbRvxQIKNCyxc2UcljpsmLpnksr1NCRn3YAQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.7.tgz", + "integrity": "sha512-8DWrSapEA9Z/YJL9dFmj7sSbemmeQmf0VkGkW6Sn1itwKFf0n9hzTiwWt8XBtZvV5JVDNCtvRZKYYKgNjDcYPg==", "license": "MIT", "dependencies": { - "multer": "^2.2.0", - "path-to-regexp": "^8.4.2", - "rxjs": "^7.8.2" + "multer": "^2.3.0", + "path-to-regexp": "^8.4.2" }, "peerDependencies": { "@modelcontextprotocol/core": "^2.0.0-beta.5", @@ -13630,9 +13629,10 @@ "@nestjs/common": ">=9.0.0", "@nestjs/core": ">=9.0.0", "@nestjs/microservices": ">=9.0.0", - "@nestjs/platform-fastify": "^11.1.5", + "@nestjs/platform-fastify": "^11.1.5 || ^12.0.0", "express": ">=4.0.0", "reflect-metadata": "^0.2.2", + "rxjs": "^7.1.0", "zod": "^4.3.5" }, "peerDependenciesMeta": { @@ -13641,13 +13641,65 @@ } } }, - "node_modules/@rekog/mcp-nest/node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", + "node_modules/@rekog/mcp-nest/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@rekog/mcp-nest/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@rekog/mcp-nest/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { - "tslib": "^2.1.0" + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@rekog/mcp-nest/node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@rekog/mcp-nest/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/@rolldown/binding-android-arm64": { diff --git a/package.json b/package.json index 83aff10108..638fd1ed10 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@openrouter/ai-sdk-provider": "3.0.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", - "@rekog/mcp-nest": "2.0.2", + "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", "ai": "7.0.37",