Browse Source

Filter MCP tools by scopes of access

pull/7935/head
Thomas Kaul 5 days ago
parent
commit
d724a63978
  1. 104
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 15
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  3. 9
      apps/api/src/decorators/requires-scope-of-access.decorator.ts
  4. 8
      apps/api/src/guards/access.guard.ts
  5. 31
      apps/api/src/helper/bearer-token.helper.spec.ts
  6. 29
      apps/api/src/helper/bearer-token.helper.ts

104
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 { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; 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 { import {
EXCEPTION_FILTERS_METADATA, EXCEPTION_FILTERS_METADATA,
GUARDS_METADATA GUARDS_METADATA
} from '@nestjs/common/constants'; } from '@nestjs/common/constants';
import { import {
AccessMatchMode,
MCP_SCOPES_MATCH_METADATA_KEY,
MCP_SCOPES_METADATA_KEY, MCP_SCOPES_METADATA_KEY,
MCP_TOOL_METADATA_KEY, MCP_TOOL_METADATA_KEY,
ToolAuthorizationService,
ToolMetadata ToolMetadata
} from '@rekog/mcp-nest'; } 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<ToolMetadata>(
MCP_TOOL_METADATA_KEY,
methodName
),
requiredScopes: getMetadataOfMethod<string[]>(
MCP_SCOPES_METADATA_KEY,
methodName
),
requiredScopesMatch: getMetadataOfMethod<AccessMatchMode>(
MCP_SCOPES_MATCH_METADATA_KEY,
methodName
)
}
};
})
.filter((tool) => {
return toolAuthorizationService.canAccessTool(user, tool);
})
.map(({ metadata: { name } }) => {
return name;
});
}
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
// A tool without the decorator of the scope would be open to every access, // A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope // hence a new tool has to declare its scope
@ -75,21 +119,57 @@ describe('GhostfolioMcpController', () => {
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
}); });
// The transport lists a tool only for an access whose scopes cover the it('Lists only the tools to read for an access with the permission "Restricted view"', () => {
// scopes of the decorator ToolScopes, hence they have to be the scopes expect(
// which the guard evaluates getNamesOfListedTools({
it('Lists each tool by the scopes of its guard', () => { impersonationOfBearerToken: {
const toolMethodNames = getToolMethodNames(); 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) { it('Lists no tool for an inactive access', () => {
expect( expect(
getMetadataOfMethod<string[]>(MCP_SCOPES_METADATA_KEY, toolMethodName) getNamesOfListedTools({
).toEqual( impersonationOfBearerToken: {
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, toolMethodName) isActive: false,
); scopes: getScopesOfAccessLevel('CREATE_READ_RESTRICTED_UPDATE_DELETE')
} }
})
).toEqual([]);
}); });
it('Requires the scope to create an activity for the tool to import activities', () => { it('Requires the scope to create an activity for the tool to import activities', () => {

15
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 { 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';
import { environment } from '@ghostfolio/api/environments/environment'; 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { import {
@ -39,18 +39,9 @@ import { McpService } from './mcp.service';
return new McpStrategy({ return new McpStrategy({
instructions: 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', name: 'ghostfolio',
// The authorization middleware of the endpoint resolves the access resolveUser: getMcpUserOfBearerToken,
// 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', title: 'Ghostfolio',
transports: [ transports: [
new StreamableHttpTransport({ new StreamableHttpTransport({

9
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 * 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 * 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. * transport refuses the call of a tool which it does not list with a protocol
* At least one scope is required, because the transport refuses an empty * error before the guards and the filter of the exceptions run.
* list.
*/ */
export function RequiresScopeOfAccess(...requiredScopes: Scope[]) { export function RequiresScopeOfAccess(scope: Scope, ...otherScopes: Scope[]) {
const requiredScopes = [scope, ...otherScopes];
return applyDecorators( return applyDecorators(
SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes),
ToolScopes(requiredScopes), ToolScopes(requiredScopes),

8
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 { getRequest } from '@ghostfolio/api/helper/execution-context.helper';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -28,14 +29,17 @@ export class AccessGuard implements CanActivate {
public canActivate(context: ExecutionContext) { public canActivate(context: ExecutionContext) {
const request = getRequest<RequestWithUser>(context); const request = getRequest<RequestWithUser>(context);
if (!request?.impersonationOfBearerToken?.isActive) { const impersonationOfBearerToken =
getActiveImpersonationOfBearerToken(request);
if (!impersonationOfBearerToken) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
request.impersonation = request.impersonationOfBearerToken; request.impersonation = impersonationOfBearerToken;
return true; return true;
} }

31
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();
});
});

29
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 '; const PREFIX_OF_BEARER_TOKEN = 'bearer ';
/** /**
@ -16,3 +18,30 @@ export function getAccessIdOfBearerToken(authorization?: string) {
? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined ? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined
: 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<RequestWithUser, 'impersonationOfBearerToken'>
) {
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;
}

Loading…
Cancel
Save