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 9d80321475..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,15 +1,24 @@ 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 { + AccessMatchMode, + MCP_SCOPES_MATCH_METADATA_KEY, MCP_SCOPES_METADATA_KEY, MCP_TOOL_METADATA_KEY, + ToolAuthorizationService, ToolMetadata } from '@rekog/mcp-nest'; @@ -39,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 @@ -75,21 +119,57 @@ 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(); + 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' + ]); + }); - expect(toolMethodNames.length).toBeGreaterThan(0); + 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' + ]); + }); - for (const toolMethodName of toolMethodNames) { - expect( - getMetadataOfMethod(MCP_SCOPES_METADATA_KEY, toolMethodName) - ).toEqual( - getMetadataOfMethod(REQUIRES_SCOPE_KEY, toolMethodName) - ); - } + 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', () => { diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index dd96992645..1da7a06823 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -2,12 +2,12 @@ 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'; 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 { @@ -39,18 +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', - // 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; - }, + 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 450e202d7d..f0d35effcb 100644 --- a/apps/api/src/decorators/requires-scope-of-access.decorator.ts +++ b/apps/api/src/decorators/requires-scope-of-access.decorator.ts @@ -13,11 +13,12 @@ import { ToolScopes } from '@rekog/mcp-nest'; * * 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. + * 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), 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; +}