From 37e10a0889f334cde4898da6f3a6995971a0a3d6 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:22:39 +0200 Subject: [PATCH] Filter MCP tools by scopes of access --- .../app/endpoints/mcp/mcp.controller.spec.ts | 21 ++++++++++++++++++- .../src/app/endpoints/mcp/mcp.controller.ts | 8 +------ apps/api/src/app/endpoints/mcp/mcp.module.ts | 11 ++++++++++ .../requires-scope-of-access.decorator.ts | 8 +++++++ 4 files changed, 40 insertions(+), 8 deletions(-) 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..29eaf5e9ce 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -7,7 +7,11 @@ import { EXCEPTION_FILTERS_METADATA, GUARDS_METADATA } from '@nestjs/common/constants'; -import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest'; +import { + MCP_SCOPES_METADATA_KEY, + MCP_TOOL_METADATA_KEY, + ToolMetadata +} from '@rekog/mcp-nest'; import { GhostfolioMcpController } from './mcp.controller'; @@ -71,6 +75,21 @@ describe('GhostfolioMcpController', () => { expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); }); + // The transport lists a tool only for an access whose scopes cover the + // scopes of the decorator ToolScopes, hence they have to be the scopes + // which the guard evaluates + it('Lists each tool by the scopes of its guard', () => { + const toolMethodNames = getToolMethodNames(); + + expect(toolMethodNames.length).toBeGreaterThan(0); + + for (const methodName of toolMethodNames) { + expect( + getMetadataOfMethod(MCP_SCOPES_METADATA_KEY, methodName) + ).toEqual(getMetadataOfMethod(REQUIRES_SCOPE_KEY, methodName)); + } + }); + 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..dd96992645 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -7,6 +7,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { MCP_ENDPOINT } from '@ghostfolio/common/config'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { Module } from '@nestjs/common'; import { @@ -40,6 +41,16 @@ import { McpService } from './mcp.service'; 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).', name: 'ghostfolio', + // The authorization middleware of the endpoint resolves the access + // and puts it on the request, hence the transport reads the scopes + // of the access and lists only the tools which they cover + resolveUser: (request: unknown) => { + const { impersonationOfBearerToken } = request as RequestWithUser; + + return impersonationOfBearerToken?.isActive + ? { scopes: impersonationOfBearerToken.scopes } + : undefined; + }, 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..450e202d7d 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,23 @@ 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 + * guards refuse the call nevertheless, hence a hidden tool is also refused. + * At least one scope is required, because the transport refuses an empty + * list. */ export function RequiresScopeOfAccess(...requiredScopes: Scope[]) { return applyDecorators( SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), + ToolScopes(requiredScopes), UseGuards(AccessGuard, ScopeGuard) ); }