Browse Source

Filter MCP tools by scopes of access

pull/7935/head
Thomas Kaul 6 days ago
parent
commit
37e10a0889
  1. 21
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 8
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 11
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 8
      apps/api/src/decorators/requires-scope-of-access.decorator.ts

21
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<string[]>(MCP_SCOPES_METADATA_KEY, methodName)
).toEqual(getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName));
}
});
it('Requires the scope to create an activity for the tool to import activities', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')

8
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
})

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

8
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)
);
}

Loading…
Cancel
Save