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 { 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<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', () => {
// 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) {
it('Lists no tool for an inactive access', () => {
expect(
getMetadataOfMethod<string[]>(MCP_SCOPES_METADATA_KEY, toolMethodName)
).toEqual(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, toolMethodName)
);
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', () => {

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 { 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({

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
* 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),

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 type { RequestWithUser } from '@ghostfolio/common/types';
@ -28,14 +29,17 @@ export class AccessGuard implements CanActivate {
public canActivate(context: ExecutionContext) {
const request = getRequest<RequestWithUser>(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;
}

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 ';
/**
@ -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<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