diff --git a/.gitignore b/.gitignore index 8071bf0b9..58520c230 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ npm-debug.log .env .env.prod .github/instructions/nx.instructions.md +.mcp.json .nx/cache .nx/migrate-runs .nx/polygraph diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ef271cc..cb4920012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended the holdings table by the activities count in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental) - Extended the holdings table by the date of first activity in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental) - Extended the holdings table by the date of first activity in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental) +- Added a server of the Model Context Protocol (MCP) with a tool to get the holdings of the portfolio (experimental) +- Added the `type` to the `Access` database schema ### Fixed +- Fixed an issue in the create or update access dialog where a public access could not be updated (experimental) - Fixed the performance calculation for dates without historical market data by carrying forward the market price from dates with activities ## 3.58.0 - 2026-08-22 diff --git a/README.md b/README.md index 69192124f..cb69cdc64 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Find answers to commonly asked questions about self-hosting Ghostfolio in our [F | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | @@ -355,6 +356,27 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_. } ``` +## Model Context Protocol (experimental) + +The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio. + +### Prerequisites + +- Set `ENABLE_FEATURE_MCP` to `true` +- Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin. +- Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its identifier + +An _MCP_ access has (restricted) read scopes. It can neither change data nor read the monetary values. + +### Connect a client + +Point the client to the endpoint below and set the identifier of the access as the _Bearer Token_. + +``` +POST http://localhost:3333/mcp +"Authorization": "Bearer " +``` + ## Community Projects Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index b8772e3bb..00e1fae98 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -4,6 +4,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { isValidGranteeOfAccess } from '@ghostfolio/common/helper'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { getScopesOfAccess } from '@ghostfolio/common/scopes'; @@ -49,27 +50,15 @@ export class AccessController { }); return accessesWithGranteeUser.map((accessItem) => { - const { alias, granteeUser, id, settings } = accessItem; - const scopes = getScopesOfAccess(accessItem); - - if (granteeUser) { - return { - alias, - id, - scopes, - grantee: granteeUser?.id, - settings: settings as AccessSettings, - type: 'PRIVATE' - }; - } + const { alias, granteeUser, id, settings, type } = accessItem; return { alias, id, - scopes, - grantee: 'Public', - settings: settings as AccessSettings, - type: 'PUBLIC' + type, + grantee: granteeUser?.id, + scopes: getScopesOfAccess(accessItem), + settings: settings as AccessSettings }; }); } @@ -90,14 +79,37 @@ export class AccessController { ); } + const type = data.type ?? (data.granteeUserId ? 'PRIVATE' : 'PUBLIC'); + + if ( + type === 'MCP' && + !this.configurationService.get('ENABLE_FEATURE_MCP') + ) { + // The client hides the type while the feature is disabled, hence an + // access of this type must not become a credential which is dormant + // until the feature is enabled + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + if (!isValidGranteeOfAccess({ granteeUserId: data.granteeUserId, type })) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + try { return await this.accessService.createAccess({ + type, alias: data.alias || undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, scopes: getScopesOfAccess({ - granteeUserId: data.granteeUserId, + type, scopes: data.scopes }), settings: this.accessService.buildSettings(data.filters), @@ -164,6 +176,18 @@ export class AccessController { ); } + if ( + !isValidGranteeOfAccess({ + granteeUserId: data.granteeUserId, + type: originalAccess.type + }) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + try { return await this.accessService.updateAccess({ data: { @@ -172,8 +196,8 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : { disconnect: true }, scopes: getScopesOfAccess({ - granteeUserId: data.granteeUserId, - scopes: data.scopes ?? originalAccess.scopes + scopes: data.scopes ?? originalAccess.scopes, + type: originalAccess.type }), settings: this.accessService.buildSettings(data.filters) }, diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index e79abca50..33617602d 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -50,6 +50,7 @@ import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module'; +import { McpModule } from './endpoints/mcp/mcp.module'; import { PlatformsModule } from './endpoints/platforms/platforms.module'; import { PublicModule } from './endpoints/public/public.module'; import { SitemapModule } from './endpoints/sitemap/sitemap.module'; @@ -128,6 +129,7 @@ import { UserModule } from './user/user.module'; InfoModule, LogoModule, MarketDataModule, + McpModule, PlatformModule, PlatformsModule, PortfolioModule, diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 653e4423a..5c49d74cd 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -48,6 +48,12 @@ export class AiService { private readonly propertyService: PropertyService ) {} + public static getHoldingsTableColumnNames() { + return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { + return name; + }); + } + public async generateText({ prompt, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts new file mode 100644 index 000000000..ce4205748 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -0,0 +1,42 @@ +import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; +import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; +import { scopes } from '@ghostfolio/common/scopes'; +import type { ImpersonationContext } from '@ghostfolio/common/types'; + +import { UseFilters } from '@nestjs/common'; +import { McpController, Tool } from '@rekog/mcp-nest'; + +@McpController() +@UseFilters(McpToolExceptionFilter) +export class GhostfolioMcpController { + public constructor(private readonly aiService: AiService) {} + + @RequiresScopeOfAccess(scopes.portfolioRead) + @Tool({ + annotations: { + openWorldHint: false, + readOnlyHint: true, + title: 'Get portfolio' + }, + description: `Gives the holdings of the portfolio with these columns: ${AiService.getHoldingsTableColumnNames().join( + ', ' + )}.`, + name: 'get-portfolio' + }) + public async getPortfolio( + @Impersonation() { filters, userId, userSettings }: ImpersonationContext + ) { + const prompt = await this.aiService.getPrompt({ + filters, + userId, + languageCode: userSettings.language ?? DEFAULT_LANGUAGE_CODE, + mode: 'portfolio', + userCurrency: userSettings.baseCurrency + }); + + return { content: [{ text: prompt, type: 'text' as const }] }; + } +} diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts new file mode 100644 index 000000000..e77bc3b19 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -0,0 +1,47 @@ +import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; +import { environment } from '@ghostfolio/api/environments/environment'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { MCP_ENDPOINT } from '@ghostfolio/common/config'; + +import { Module } from '@nestjs/common'; +import { + MCP_STRATEGY, + McpStrategy, + StreamableHttpTransport +} from '@rekog/mcp-nest'; + +import { GhostfolioMcpController } from './mcp.controller'; + +@Module({ + controllers: [GhostfolioMcpController], + imports: [AiModule, ConfigurationModule], + providers: [ + { + inject: [ConfigurationService], + provide: MCP_STRATEGY, + useFactory: (configurationService: ConfigurationService) => { + const { hostname } = new URL(configurationService.get('ROOT_URL')); + + return new McpStrategy({ + instructions: + 'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access. They give no monetary value.', + name: 'ghostfolio', + title: 'Ghostfolio', + transports: [ + new StreamableHttpTransport({ + endpoint: MCP_ENDPOINT, + security: { + allowedHosts: [hostname], + allowedOrigins: [hostname] + } + }) + ], + version: environment.version, + websiteUrl: 'https://ghostfol.io' + }); + } + } + ] +}) +export class McpModule {} diff --git a/apps/api/src/app/endpoints/public/public.service.ts b/apps/api/src/app/endpoints/public/public.service.ts index c27579228..277cc0d8c 100644 --- a/apps/api/src/app/endpoints/public/public.service.ts +++ b/apps/api/src/app/endpoints/public/public.service.ts @@ -36,8 +36,8 @@ export class PublicService { accessId: string ): Promise { const access = await this.accessService.access({ - granteeUserId: null, - id: accessId + id: accessId, + type: 'PUBLIC' }); if (!access) { diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index cb7d24bcb..696507314 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -75,6 +75,10 @@ export class InfoService { globalPermissions.push(permissions.enableFearAndGreedIndex); } + if (this.configurationService.get('ENABLE_FEATURE_MCP')) { + globalPermissions.push(permissions.enableMcp); + } + if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { isReadOnlyMode = await this.propertyService.getByKey( PROPERTY_IS_READ_ONLY_MODE diff --git a/apps/api/src/decorators/impersonation.decorator.ts b/apps/api/src/decorators/impersonation.decorator.ts index 0fe95f29f..fd5fcf955 100644 --- a/apps/api/src/decorators/impersonation.decorator.ts +++ b/apps/api/src/decorators/impersonation.decorator.ts @@ -1,3 +1,4 @@ +import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; import type { ImpersonationContext, RequestWithUser @@ -17,9 +18,7 @@ import { */ export const Impersonation = createParamDecorator( (_data: unknown, context: ExecutionContext): ImpersonationContext => { - const { impersonation } = context - .switchToHttp() - .getRequest(); + const { impersonation } = getRequest(context) ?? {}; if (!impersonation) { throw new InternalServerErrorException( diff --git a/apps/api/src/decorators/requires-scope-of-access.decorator.ts b/apps/api/src/decorators/requires-scope-of-access.decorator.ts new file mode 100644 index 000000000..b76f1441c --- /dev/null +++ b/apps/api/src/decorators/requires-scope-of-access.decorator.ts @@ -0,0 +1,18 @@ +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; +import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; +import { Scope } from '@ghostfolio/common/scopes'; + +import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; + +/** + * 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. + */ +export function RequiresScopeOfAccess(...requiredScopes: Scope[]) { + return applyDecorators( + SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), + UseGuards(AccessGuard, ScopeGuard) + ); +} diff --git a/apps/api/src/filters/mcp-tool-exception.filter.ts b/apps/api/src/filters/mcp-tool-exception.filter.ts new file mode 100644 index 000000000..7525c6559 --- /dev/null +++ b/apps/api/src/filters/mcp-tool-exception.filter.ts @@ -0,0 +1,62 @@ +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; + +import { + Catch, + HttpException, + Logger, + RpcExceptionFilter +} from '@nestjs/common'; +import { RpcException } from '@nestjs/microservices'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { Observable, throwError } from 'rxjs'; + +/** + * Turns an exception of a tool of the model context protocol into an error of + * the caller. Only a message which is written for the caller is passed on, so + * that an unexpected exception cannot expose internals of the application. + */ +@Catch() +export class McpToolExceptionFilter implements RpcExceptionFilter { + private readonly logger = new Logger(McpToolExceptionFilter.name); + + public catch(exception: unknown): Observable { + this.logger.error(exception); + + if (exception instanceof RpcException) { + return throwError(() => { + return exception.getError(); + }); + } + + return throwError(() => { + return { message: this.getMessage(exception), status: 'error' }; + }); + } + + private getMessage(exception: unknown) { + // The message of an exception can carry internals, for example the + // property names of a data transfer object of a failed validation, hence + // the reason phrase of the status is passed on instead + return this.getReasonPhraseOfStatus(this.getStatus(exception)); + } + + private getReasonPhraseOfStatus(statusCode: number) { + try { + return getReasonPhrase(statusCode); + } catch { + return getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR); + } + } + + private getStatus(exception: unknown) { + if (exception instanceof PortfolioSnapshotComputationError) { + return StatusCodes.SERVICE_UNAVAILABLE; + } + + if (exception instanceof HttpException) { + return exception.getStatus(); + } + + return StatusCodes.INTERNAL_SERVER_ERROR; + } +} diff --git a/apps/api/src/guards/access.guard.ts b/apps/api/src/guards/access.guard.ts new file mode 100644 index 000000000..87a1f2469 --- /dev/null +++ b/apps/api/src/guards/access.guard.ts @@ -0,0 +1,42 @@ +import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +/** + * Admits a request which is identified by an access instead of by an + * authenticated user, for example a request of a client of the model context + * protocol. The access is the bearer token, which is the credential such a + * client expects and which a token of an authorization server can replace + * later. + * + * The authorization middleware of the endpoint resolves the bearer token + * before the transport reads the message, because the specification asks for + * the status 401 and a challenge, which a guard can no longer set. This guard + * therefore accepts the context of that middleware only. It never accepts the + * context of the ImpersonationGuard, which comes from the Impersonation-Id + * header of an authenticated user and is not a bearer token. + */ +@Injectable() +export class AccessGuard implements CanActivate { + public canActivate(context: ExecutionContext) { + const request = getRequest(context); + + if (!request?.impersonationOfBearerToken?.isActive) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + request.impersonation = request.impersonationOfBearerToken; + + return true; + } +} diff --git a/apps/api/src/guards/impersonation-write.guard.ts b/apps/api/src/guards/impersonation-write.guard.ts index ed961c778..1106ac057 100644 --- a/apps/api/src/guards/impersonation-write.guard.ts +++ b/apps/api/src/guards/impersonation-write.guard.ts @@ -1,5 +1,6 @@ import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; +import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { SCOPES_OF_WRITE_ACCESS, Scope } from '@ghostfolio/common/scopes'; @@ -29,12 +30,15 @@ export class ImpersonationWriteGuard implements CanActivate { public constructor(private readonly reflector: Reflector) {} public canActivate(context: ExecutionContext): boolean { - if (context.getType() !== 'http') { + const request = getRequest<{ + headers?: Record; + method?: string; + }>(context); + + if (!request) { return true; } - const request = context.switchToHttp().getRequest(); - if (request.method === 'GET') { return true; } diff --git a/apps/api/src/guards/impersonation.guard.ts b/apps/api/src/guards/impersonation.guard.ts index 1269cff85..4a4fb5b9c 100644 --- a/apps/api/src/guards/impersonation.guard.ts +++ b/apps/api/src/guards/impersonation.guard.ts @@ -1,3 +1,4 @@ +import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { HEADER_KEY_IMPERSONATION, @@ -26,7 +27,11 @@ export class ImpersonationGuard implements CanActivate { ) {} public async canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); + const request = getRequest(context); + + if (!request) { + return true; + } const impersonationId = request.headers?.[ HEADER_KEY_IMPERSONATION.toLowerCase() diff --git a/apps/api/src/guards/scope.guard.ts b/apps/api/src/guards/scope.guard.ts index 025a85502..87442a716 100644 --- a/apps/api/src/guards/scope.guard.ts +++ b/apps/api/src/guards/scope.guard.ts @@ -1,4 +1,5 @@ import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; +import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; import { hasScope, Scope } from '@ghostfolio/common/scopes'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -30,9 +31,7 @@ export class ScopeGuard implements CanActivate { return true; } - const { impersonation } = context - .switchToHttp() - .getRequest(); + const { impersonation } = getRequest(context) ?? {}; const hasRequiredScopes = requiredScopes.every((scope) => { return hasScope(impersonation?.scopes, scope); diff --git a/apps/api/src/helper/bearer-token.helper.ts b/apps/api/src/helper/bearer-token.helper.ts new file mode 100644 index 000000000..5641016f5 --- /dev/null +++ b/apps/api/src/helper/bearer-token.helper.ts @@ -0,0 +1,18 @@ +const PREFIX_OF_BEARER_TOKEN = 'bearer '; + +/** + * Gives the identifier of the access which the authorization header carries as + * a bearer token. The scheme is compared without regard to the case, because + * RFC 7235 defines it as case-insensitive. + */ +export function getAccessIdOfBearerToken(authorization?: string) { + if (typeof authorization !== 'string') { + return undefined; + } + + const value = authorization.trim(); + + return value.toLowerCase().startsWith(PREFIX_OF_BEARER_TOKEN) + ? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined + : undefined; +} diff --git a/apps/api/src/helper/execution-context.helper.ts b/apps/api/src/helper/execution-context.helper.ts new file mode 100644 index 000000000..74eca7af7 --- /dev/null +++ b/apps/api/src/helper/execution-context.helper.ts @@ -0,0 +1,19 @@ +import { ExecutionContext } from '@nestjs/common'; + +/** + * Gives the underlying HTTP request of a request of any kind. A tool of the + * model context protocol is a message handler, hence its execution context is + * not of the kind http and the request has to be taken from the context of the + * message instead. + */ +export function getRequest(context: ExecutionContext): T | undefined { + if (context.getType() === 'http') { + return context.switchToHttp().getRequest(); + } + + const contextOfMessage = context.switchToRpc().getContext<{ + getRawRequest?: () => U | undefined; + }>(); + + return contextOfMessage?.getRawRequest?.(); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b30a20323..3d4ddf092 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,9 +1,12 @@ import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; +import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { BULL_BOARD_ROUTE, DEFAULT_HOST, DEFAULT_PORT, + MCP_ENDPOINT, STORYBOOK_PATH, SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; @@ -17,6 +20,7 @@ import { import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; +import { MCP_STRATEGY, McpStrategy } from '@rekog/mcp-nest'; import cookieParser from 'cookie-parser'; import { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; @@ -132,6 +136,42 @@ async function bootstrap() { const HOST = configService.get('HOST') || DEFAULT_HOST; const PORT = configService.get('PORT') || DEFAULT_PORT; + if (configurationService.get('ENABLE_FEATURE_MCP')) { + if (!process.env.ROOT_URL) { + const { hostname } = new URL(configurationService.get('ROOT_URL')); + + logger.warn( + `The Model Context Protocol (MCP) is enabled, but ROOT_URL is not set. A client which calls the endpoint from a browser page is accepted only with the origin ${hostname}.` + ); + } + + // The middleware has to be registered before the transport mounts its + // route, so that an unauthorized request gets the status 401 of the + // specification instead of an error of the protocol + app.use( + MCP_ENDPOINT, + createMcpAuthorizationMiddleware(app.get(ImpersonationService)) + ); + + const mcpStrategy = app.get(MCP_STRATEGY); + + // The transports of the model context protocol have to be mounted before + // the server accepts connections + mcpStrategy.setHttpAdapter(app.getHttpAdapter()); + + // The configuration of the application is inherited, so that the global + // filters and pipes also apply to a tool of the model context protocol. + // The ImpersonationWriteGuard does not protect such a tool, because it + // keys on the Impersonation-Id header, which a client of the protocol + // never sends. A tool which changes data has to be guarded on its own. + app.connectMicroservice( + { strategy: mcpStrategy }, + { inheritAppConfig: true } + ); + + await app.startAllMicroservices(); + } + await app.listen(PORT, HOST, () => { logLogo(); diff --git a/apps/api/src/middlewares/mcp-authorization.middleware.ts b/apps/api/src/middlewares/mcp-authorization.middleware.ts new file mode 100644 index 000000000..ba3a96053 --- /dev/null +++ b/apps/api/src/middlewares/mcp-authorization.middleware.ts @@ -0,0 +1,61 @@ +import { getAccessIdOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_TOKEN, MCP_REALM } from '@ghostfolio/common/config'; +import type { ImpersonationContext } from '@ghostfolio/common/types'; + +import { NextFunction, Request, Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +/** + * Authenticates a request of a client of the model context protocol before the + * transport reads it. The specification tells a server to answer an + * unauthorized request with the status 401 and a challenge, hence the answer + * cannot come from a guard: a guard runs while the transport dispatches the + * message and can no longer set the status of the response. + * + * The challenge names no resource metadata, because Ghostfolio has no + * authorization server. A client which reads the challenge asks the user for + * the identifier of the access instead of starting a flow which would fail. + */ +export function createMcpAuthorizationMiddleware( + impersonationService: ImpersonationService +) { + return async ( + request: Request & { impersonationOfBearerToken?: ImpersonationContext }, + response: Response, + next: NextFunction + ) => { + const accessId = getAccessIdOfBearerToken( + request.headers[HEADER_KEY_TOKEN.toLowerCase()] as string + ); + + const impersonation = await impersonationService.resolve({ + impersonationId: accessId, + types: ['MCP'] + }); + + if (!impersonation.isActive) { + const parameters = [`realm="${MCP_REALM}"`]; + + if (accessId) { + parameters.push( + 'error="invalid_token"', + 'error_description="The access cannot be resolved"' + ); + } + + response.setHeader('WWW-Authenticate', `Bearer ${parameters.join(', ')}`); + + return response.status(StatusCodes.UNAUTHORIZED).json({ + error: getReasonPhrase(StatusCodes.UNAUTHORIZED), + message: 'A valid access is required as a bearer token' + }); + } + + // The guard of the route reads the resolved context from the same request, + // hence the access is looked up once + request.impersonationOfBearerToken = impersonation; + + return next(); + }; +} diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 3c152d5df..baf1e9c62 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -72,6 +72,7 @@ export class ConfigurationService { ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), + ENABLE_FEATURE_MCP: bool({ default: false }), ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), diff --git a/apps/api/src/services/impersonation/impersonation.service.spec.ts b/apps/api/src/services/impersonation/impersonation.service.spec.ts index d78a66f9a..b333f8270 100644 --- a/apps/api/src/services/impersonation/impersonation.service.spec.ts +++ b/apps/api/src/services/impersonation/impersonation.service.spec.ts @@ -50,8 +50,35 @@ describe('Impersonation service', () => { const prismaService = { access: { - findFirst: async () => { - return access ?? null; + findFirst: async ({ + where + }: { + where?: { + granteeUserId?: string; + id?: string; + type?: { in?: string[] }; + }; + }) => { + if (!access) { + return null; + } + + if ( + where?.granteeUserId && + where.granteeUserId !== access.granteeUserId + ) { + return null; + } + + if (where?.id && where.id !== access.id) { + return null; + } + + if (where?.type?.in && !where.type.in.includes(access.type)) { + return null; + } + + return access; } }, user: { @@ -104,8 +131,8 @@ describe('Impersonation service', () => { const grantedAccess = { granteeUserId: authenticatedUserId, id: accessId, - permissions: ['READ'], scopes: [scopes.portfolioRead], + type: 'PRIVATE', userId: impersonatedUserId } as unknown as Access; @@ -227,6 +254,111 @@ describe('Impersonation service', () => { }); }); + // A client of the model context protocol has no authenticated user, hence + // the access itself is the credential + describe('With an access as the credential', () => { + const accessOfMcp = { + granteeUserId: null, + id: accessId, + scopes: [scopes.portfolioRead], + settings: {}, + type: 'MCP', + userId: impersonatedUserId + } as unknown as Access; + + const impersonatedUser = { + createdAt: new Date('2024-01-01'), + id: impersonatedUserId, + settings: { settings: { baseCurrency: 'USD' } }, + subscriptions: [] + }; + + it('Resolves the scopes of the access', async () => { + const { + isActive, + scopes: scopesOfAccess, + userId + } = await createService({ + access: accessOfMcp, + impersonatedUser + }).service.resolve({ impersonationId: accessId, types: ['MCP'] }); + + expect(isActive).toEqual(true); + expect(scopesOfAccess).toEqual([scopes.portfolioRead]); + expect(userId).toEqual(impersonatedUserId); + }); + + // The absence of the types is what stops an access from becoming a + // credential, hence a caller which omits them gets nothing + it('Refuses the identifier without the types', async () => { + const { isActive, userId } = await createService({ + access: accessOfMcp, + impersonatedUser + }).service.resolve({ impersonationId: accessId }); + + expect(isActive).toEqual(false); + expect(userId).toBeUndefined(); + }); + + it('Refuses the identifier with an empty list of types', async () => { + const { isActive, userId } = await createService({ + access: accessOfMcp, + impersonatedUser + }).service.resolve({ impersonationId: accessId, types: [] }); + + expect(isActive).toEqual(false); + expect(userId).toBeUndefined(); + }); + + it('Refuses an access of the type PRIVATE', async () => { + const { isActive, userId } = await createService({ + access: { ...accessOfMcp, type: 'PRIVATE' } as unknown as Access, + impersonatedUser + }).service.resolve({ impersonationId: accessId, types: ['MCP'] }); + + expect(isActive).toEqual(false); + expect(userId).toBeUndefined(); + }); + + it('Refuses an access of the type PUBLIC', async () => { + const { isActive, userId } = await createService({ + access: { ...accessOfMcp, type: 'PUBLIC' } as unknown as Access, + impersonatedUser + }).service.resolve({ impersonationId: accessId, types: ['MCP'] }); + + expect(isActive).toEqual(false); + expect(userId).toBeUndefined(); + }); + + // The identifier is the one of the access and not the one of the user who + // granted it, hence an access can never be resolved by another identifier + it('Refuses the identifier of another access', async () => { + const { isActive, userId } = await createService({ + access: accessOfMcp, + impersonatedUser + }).service.resolve({ + impersonationId: 'b7c9a0d3-f2c1-4c8a-8f2d-1e6b5d3f2c19', + types: ['MCP'] + }); + + expect(isActive).toEqual(false); + expect(userId).toBeUndefined(); + }); + + it('Refuses the identifier of the user who granted the access', async () => { + const { isActive, userId } = await createService({ + access: accessOfMcp, + impersonatedUser + }).service.resolve({ + impersonationId: impersonatedUserId, + types: ['MCP'] + }); + + expect(isActive).toEqual(false); + expect(userId).toBeUndefined(); + }); + }); + // The guard rejects the request in this case, hence the context must not // present the data of the authenticated user as impersonated data describe('With an identifier which cannot be resolved', () => { diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index e1df1d3ce..5dfb5b0f6 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -2,7 +2,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; -import { UserSettings } from '@ghostfolio/common/interfaces'; +import { AccessSettings, UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { getScopesOfAccess, @@ -15,7 +15,7 @@ import type { } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { Access } from '@prisma/client'; +import { Access, AccessType } from '@prisma/client'; @Injectable() export class ImpersonationService { @@ -25,15 +25,23 @@ export class ImpersonationService { private readonly subscriptionService: SubscriptionService ) {} + /** + * Gives the context of the identifier. The types are the kinds of access + * which the caller accepts as a credential of its own, hence a caller which + * has no authenticated user has to name them. An empty list rejects every + * identifier, so that an access can never be a credential by accident. + */ public async resolve({ impersonationId, + types, user }: { impersonationId?: string; + types?: AccessType[]; user?: UserWithSettings; }): Promise { const { access, userId: impersonatedUserId } = - await this.validateImpersonation({ impersonationId, user }); + await this.validateImpersonation({ impersonationId, types, user }); if (!impersonatedUserId) { return { @@ -55,9 +63,11 @@ export class ImpersonationService { where: { id: impersonatedUserId } }); + const { filters } = (access?.settings ?? {}) as AccessSettings; const settings = impersonatedUser?.settings?.settings as UserSettings; return { + filters, accessId: impersonationId, authenticatedUserSubscription: user?.subscription, isActive: true, @@ -83,9 +93,11 @@ export class ImpersonationService { private async validateImpersonation({ impersonationId, + types, user }: { impersonationId?: string; + types?: AccessType[]; user?: UserWithSettings; }): Promise<{ access?: Access; userId: string | null }> { if (!impersonationId) { @@ -113,12 +125,11 @@ export class ImpersonationService { return { userId: impersonatedUser?.id ?? null }; } - } else { - // Public access + } else if (types?.length) { const accessObject = await this.prismaService.access.findFirst({ where: { - granteeUserId: null, - user: { id: impersonationId } + id: impersonationId, + type: { in: types } } }); diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 7d9bfd1d4..b4e00ce57 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -23,6 +23,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_CRON: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; + ENABLE_FEATURE_MCP: boolean; ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_STATISTICS: boolean; diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 6dde5604c..04c8249b2 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -10,7 +10,13 @@ Grantee - {{ element.grantee }} + @if (element.grantee) { + {{ element.grantee }} + } @else if (element.type === 'PUBLIC') { + Public + } @else if (element.type === 'MCP') { + MCP + } @@ -40,6 +46,13 @@ > } + } @else if (element.type === 'MCP' && hasPermissionToEnableMcp) { +
+ {{ baseUrl }}{{ mcpEndpoint }} +
+
+ Authorization: Bearer {{ element.id }} +
} @@ -79,9 +92,18 @@ } + @if (element.type === 'MCP' && hasPermissionToEnableMcp) { + + } @if ( (!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) || - element.type === 'PUBLIC' + element.type === 'PUBLIC' || + (element.type === 'MCP' && hasPermissionToEnableMcp) ) {
} diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 7477ca138..0d1e6193a 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,9 +1,12 @@ +import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { getAccessLevel } from '@ghostfolio/common/scopes'; import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon'; import { NotificationService } from '@ghostfolio/ui/notifications'; +import { DataService } from '@ghostfolio/ui/services'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { @@ -74,11 +77,16 @@ export class GfAccessTableComponent { protected readonly getAccessLevel = getAccessLevel; + protected hasPermissionToEnableMcp = false; + protected readonly isLoading = computed(() => { return !this.accesses(); }); + protected readonly mcpEndpoint = MCP_ENDPOINT; + private readonly clipboard = inject(Clipboard); + private readonly dataService = inject(DataService); private readonly notificationService = inject(NotificationService); private readonly snackBar = inject(MatSnackBar); @@ -91,6 +99,11 @@ export class GfAccessTableComponent { removeCircleOutline }); + this.hasPermissionToEnableMcp = hasPermission( + this.dataService.fetchInfo().globalPermissions, + permissions.enableMcp + ); + effect(() => { this.dataSource.data = this.accesses() ?? []; }); @@ -102,6 +115,18 @@ export class GfAccessTableComponent { return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; } + protected onCopyIdToClipboard(aId: string) { + this.clipboard.copy(aId); + + this.snackBar.open( + '✅ ' + $localize`Identifier has been copied to the clipboard`, + undefined, + { + duration: ms('3 seconds') + } + ); + } + protected onCopyUrlToClipboard(aId: string) { this.clipboard.copy(this.getPublicUrl(aId)); diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 2f61e850a..4054de639 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -1,6 +1,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Scope, getAccessLevel, @@ -81,6 +82,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { protected readonly mode: 'create' | 'update'; private hasExperimentalFeatures = false; + private hasPermissionToEnableMcp = false; private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -102,10 +104,11 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } public get canApplyFilters() { - return ( - this.accessForm?.get('type')?.value === 'PUBLIC' && - this.hasExperimentalFeatures - ); + return this.isPublicAccess && this.hasExperimentalFeatures; + } + + public get canGrantMcpAccess() { + return this.hasExperimentalFeatures && this.hasPermissionToEnableMcp; } public get canGrantWriteAccess() { @@ -114,15 +117,22 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { public ngOnInit() { const access = this.data?.access; - const isPublic = access?.type === 'PUBLIC'; + const isPrivate = (access?.type ?? 'PRIVATE') === 'PRIVATE'; + + const { globalPermissions } = this.dataService.fetchInfo(); + + this.hasPermissionToEnableMcp = hasPermission( + globalPermissions, + permissions.enableMcp + ); this.accessForm = this.formBuilder.group({ accessLevel: getAccessLevel(access?.scopes), alias: [access?.alias ?? ''], filters: [null], granteeUserId: [ - access?.grantee ?? null, - isPublic ? null : Validators.required + isPrivate ? (access?.grantee ?? null) : null, + isPrivate ? Validators.required : null ], type: [ { disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' }, @@ -151,16 +161,20 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { if (accessType === 'PRIVATE') { granteeUserIdControl?.setValidators(Validators.required); - this.accessForm.get('filters')?.setValue(null); } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); - // A public access never exposes the monetary values and never - // changes data + // An access which is not granted to a user never exposes the + // monetary values and never changes data this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED'); } + if (accessType !== 'PUBLIC') { + // Only a public access can be limited to a part of the portfolio + this.accessForm.get('filters')?.setValue(null); + } + granteeUserIdControl?.updateValueAndValidity(); this.changeDetectorRef.markForCheck(); @@ -173,6 +187,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { return this.accessForm?.get('accessLevel')?.value as AccessLevel; } + protected get isPublicAccess() { + return this.accessForm?.get('type')?.value === 'PUBLIC'; + } + protected onCancel() { this.dialogRef.close(); } @@ -213,7 +231,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { alias: this.accessForm.get('alias')?.value, filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, - scopes: this.buildScopes() + scopes: this.buildScopes(), + type: this.accessForm.get('type')?.value }; try { diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index a01b21138..131e4d782 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -23,13 +23,25 @@ /> -
- +
+ Type Private Public + @if (canGrantMcpAccess) { + Model Context Protocol (MCP) + } + @if (isPublicAccess) { + Any person who has the link can view the data. + }
diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index eb1a590d4..a21ddc343 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -268,7 +268,7 @@ export class GfUserAccountAccessComponent implements OnInit { data: { access: { alias: access.alias, - grantee: access.grantee === 'Public' ? undefined : access.grantee, + grantee: access.grantee, id: access.id, scopes: access.scopes, settings: access.settings, diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 0adae128b..2f914eebf 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -262,6 +262,9 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED = export const MAX_TOP_HOLDINGS = 50; +export const MCP_ENDPOINT = '/mcp'; +export const MCP_REALM = 'Ghostfolio'; + export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100; export const NUMERICAL_PRECISION_THRESHOLD_4_FIGURES = 1000; export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000; diff --git a/libs/common/src/lib/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts index abfaa30a4..f8d479230 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -1,7 +1,15 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { Scope, scopes } from '@ghostfolio/common/scopes'; -import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { AccessType } from '@prisma/client'; +import { + IsArray, + IsEnum, + IsIn, + IsOptional, + IsString, + IsUUID +} from 'class-validator'; export class CreateAccessDto { @IsOptional() @@ -20,4 +28,8 @@ export class CreateAccessDto { @IsIn(Object.values(scopes), { each: true }) @IsOptional() scopes?: Scope[]; + + @IsEnum(AccessType) + @IsOptional() + type?: AccessType; } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index d7e44d139..9fd4e8f38 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -13,6 +13,7 @@ import { isCurrencySymbol, isSplitRatio, isValidCustomAssetProfileSymbol, + isValidGranteeOfAccess, resolveUserSettings } from '@ghostfolio/common/helper'; import { UserSettings } from '@ghostfolio/common/interfaces'; @@ -383,6 +384,46 @@ describe('Helper', () => { }); }); + describe('Is valid grantee of access', () => { + const granteeUserId = 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d'; + + it('A private access with a grantee', () => { + expect( + isValidGranteeOfAccess({ granteeUserId, type: 'PRIVATE' }) + ).toEqual(true); + }); + + it('A private access without a grantee', () => { + expect(isValidGranteeOfAccess({ type: 'PRIVATE' })).toEqual(false); + }); + + it('A private access with an empty grantee', () => { + expect( + isValidGranteeOfAccess({ granteeUserId: null, type: 'PRIVATE' }) + ).toEqual(false); + }); + + it('A public access without a grantee', () => { + expect(isValidGranteeOfAccess({ type: 'PUBLIC' })).toEqual(true); + }); + + it('A public access with a grantee', () => { + expect(isValidGranteeOfAccess({ granteeUserId, type: 'PUBLIC' })).toEqual( + false + ); + }); + + it('An access of the model context protocol without a grantee', () => { + expect(isValidGranteeOfAccess({ type: 'MCP' })).toEqual(true); + }); + + it('An access of the model context protocol with a grantee', () => { + expect(isValidGranteeOfAccess({ granteeUserId, type: 'MCP' })).toEqual( + false + ); + }); + }); + describe('Resolve user settings', () => { const userSettings: UserSettings = { baseCurrency: 'CHF', diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 69a7d358e..eb1693b9f 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -1,5 +1,6 @@ import { NumberParser } from '@internationalized/number'; import { + AccessType, Type as ActivityType, AssetProfileOverrides, AssetSubClass, @@ -654,6 +655,21 @@ export function isValidCustomAssetProfileSymbol(aSymbol: string) { return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); } +/** + * A private access is granted to a user, while a public access and an access + * of a client of the model context protocol are credentials on their own and + * have no grantee. A row which mixes both is neither, hence it is rejected. + */ +export function isValidGranteeOfAccess({ + granteeUserId, + type +}: { + granteeUserId?: string | null; + type: AccessType; +}) { + return type === 'PRIVATE' ? !!granteeUserId : !granteeUserId; +} + export function isValidSearchQuery(aQuery: string) { return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; } diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index 54ddaecfd..1e543c86e 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -1,4 +1,4 @@ -import { AccessType } from '@ghostfolio/common/types'; +import { AccessType } from '@prisma/client'; import { AccessSettings } from './access-settings.interface'; diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 8771f51dd..753bdf131 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -41,6 +41,7 @@ export const permissions = { enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableImport: 'enableImport', + enableMcp: 'enableMcp', enableBlog: 'enableBlog', enableStatistics: 'enableStatistics', enableSubscription: 'enableSubscription', diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index afcac2920..223a394b9 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -1,4 +1,5 @@ import { + Scope, SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_WRITE_ACCESS, @@ -60,7 +61,7 @@ describe('Scopes', () => { it('Gives the scopes of the access', () => { expect( getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + type: 'PRIVATE', scopes: [scopes.portfolioRead, scopes.portfolioReadValues] }) ).toEqual([scopes.portfolioRead, scopes.portfolioReadValues]); @@ -69,23 +70,19 @@ describe('Scopes', () => { it('Without the scope to read the values', () => { expect( getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', - scopes: [scopes.portfolioRead] + scopes: [scopes.portfolioRead], + type: 'PRIVATE' }) ).not.toContain(scopes.portfolioReadValues); }); it('Without scopes', () => { - expect( - getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' - }) - ).toEqual([]); + expect(getScopesOfAccess({ type: 'PRIVATE' })).toEqual([]); }); it('Gives the write scopes', () => { const scopesOfAccess = getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + type: 'PRIVATE', scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS] }); @@ -97,7 +94,7 @@ describe('Scopes', () => { it('Drops an unknown scope', () => { expect( getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + type: 'PRIVATE', scopes: [scopes.portfolioRead, 'portfolio:write'] }) ).toEqual([scopes.portfolioRead]); @@ -107,13 +104,17 @@ describe('Scopes', () => { describe('Get scopes of public access', () => { it('Allows reading the portfolio', () => { expect( - getScopesOfAccess({ scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS] }) + getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS], + type: 'PUBLIC' + }) ).toContain(scopes.portfolioRead); }); it('Excludes the accounts and the watchlist', () => { const scopesOfAccess = getScopesOfAccess({ - scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS] + scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS], + type: 'PUBLIC' }); expect(scopesOfAccess).not.toContain(scopes.accountRead); @@ -127,14 +128,18 @@ describe('Scopes', () => { scopes.portfolioRead, scopes.portfolioReadValues, scopes.watchlistRead - ] + ], + type: 'PUBLIC' }) ).toEqual([scopes.portfolioRead]); }); it('Cannot expose the monetary values', () => { expect( - getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS] }) + getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_ACCESS], + type: 'PUBLIC' + }) ).not.toContain(scopes.portfolioReadValues); }); @@ -142,7 +147,51 @@ describe('Scopes', () => { // function is the sole barrier for a public access it('Gives no write scope', () => { expect( - getScopesOfAccess({ scopes: [...SCOPES_OF_WRITE_ACCESS] }) + getScopesOfAccess({ + scopes: [...SCOPES_OF_WRITE_ACCESS], + type: 'PUBLIC' + }) + ).toEqual([]); + }); + }); + + describe('Get scopes of access for the model context protocol', () => { + it('Allows reading the portfolio', () => { + expect( + getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_ACCESS], + type: 'MCP' + }) + ).toContain(scopes.portfolioRead); + }); + + it('Allows reading the accounts and the watchlist', () => { + const scopesOfAccess = getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_ACCESS], + type: 'MCP' + }); + + expect(scopesOfAccess).toContain(scopes.accountRead); + expect(scopesOfAccess).toContain(scopes.watchlistRead); + }); + + it('Cannot expose the monetary values', () => { + expect( + getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_ACCESS], + type: 'MCP' + }) + ).not.toContain(scopes.portfolioReadValues); + }); + + it('Gives no write scope', () => { + expect( + getScopesOfAccess({ + scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS], + type: 'MCP' + }).filter((scope) => { + return SCOPES_OF_WRITE_ACCESS.includes(scope as Scope); + }) ).toEqual([]); }); }); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index ea3c342fd..ee74e1742 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -1,5 +1,7 @@ import { AccessLevel } from '@ghostfolio/common/types'; +import { AccessType } from '@prisma/client'; + /** * Scopes describe what a grantee may do on behalf of the granting user. They * are a separate axis from the permissions, which describe the capabilities of @@ -59,6 +61,17 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = return scope !== scopes.portfolioReadValues; }); +/** + * Ceiling of scopes per access type. The scopes stored on an access are + * intersected with it, hence a scope which the type does not permit stays + * ineffective even if it is stored. + */ +const SCOPES_OF_TYPE: Record = { + MCP: SCOPES_OF_READ_RESTRICTED_ACCESS, + PRIVATE: Object.values(scopes), + PUBLIC: SCOPES_OF_PUBLIC_ACCESS +}; + /** * Access level which the scopes of an access grant */ @@ -73,25 +86,16 @@ export function getAccessLevel(aScopes: string[] = []): AccessLevel { } export function getScopesOfAccess({ - granteeUserId, - scopes: scopesOfAccess + scopes: scopesOfAccess, + type }: { - granteeUserId?: string | null; scopes?: string[]; + type: AccessType; }): string[] { const scopesToEvaluate = scopesOfAccess ?? []; - if (granteeUserId) { - // An unknown scope is dropped, so that a scope which has been removed from - // the vocabulary cannot stay effective - return Object.values(scopes).filter((scope) => { - return scopesToEvaluate.includes(scope); - }); - } - - // An access which has not been granted to a user is public, hence it is - // narrowed to the scopes exposed by the public endpoints - return SCOPES_OF_PUBLIC_ACCESS.filter((scope) => { + // An unknown scope is dropped + return SCOPES_OF_TYPE[type].filter((scope) => { return scopesToEvaluate.includes(scope); }); } diff --git a/libs/common/src/lib/types/access-type.type.ts b/libs/common/src/lib/types/access-type.type.ts deleted file mode 100644 index fa8e966aa..000000000 --- a/libs/common/src/lib/types/access-type.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type AccessType = 'PRIVATE' | 'PUBLIC'; diff --git a/libs/common/src/lib/types/impersonation-context.type.ts b/libs/common/src/lib/types/impersonation-context.type.ts index 29b7970b1..b125191db 100644 --- a/libs/common/src/lib/types/impersonation-context.type.ts +++ b/libs/common/src/lib/types/impersonation-context.type.ts @@ -1,4 +1,4 @@ -import { UserSettings } from '@ghostfolio/common/interfaces'; +import { Filter, UserSettings } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; /** @@ -10,6 +10,7 @@ import { UserWithSettings } from '@ghostfolio/common/types'; export interface ImpersonationContext { accessId?: string; authenticatedUserSubscription?: UserWithSettings['subscription']; + filters?: Filter[]; isActive: boolean; scopes: string[]; userId: string; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 7dea25ddc..9a44efc1a 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -1,5 +1,4 @@ import type { AccessLevel } from './access-level.type'; -import type { AccessType } from './access-type.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; import type { AccountWithBalance } from './account-with-balance.type'; import type { AccountWithPlatform } from './account-with-platform.type'; @@ -31,7 +30,6 @@ import type { ViewMode } from './view-mode.type'; export type { AccessLevel, - AccessType, AccessWithGranteeUser, AccountWithBalance, AccountWithPlatform, diff --git a/libs/common/src/lib/types/request-with-user.type.ts b/libs/common/src/lib/types/request-with-user.type.ts index 9e37a5686..48f47fe51 100644 --- a/libs/common/src/lib/types/request-with-user.type.ts +++ b/libs/common/src/lib/types/request-with-user.type.ts @@ -5,5 +5,6 @@ import { export type RequestWithUser = Request & { impersonation?: ImpersonationContext; + impersonationOfBearerToken?: ImpersonationContext; user: UserWithSettings; }; diff --git a/package-lock.json b/package-lock.json index 630e4f9ea..30ea96961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,9 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", @@ -37,6 +40,7 @@ "@nestjs/core": "11.1.28", "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", + "@nestjs/microservices": "11.1.28", "@nestjs/passport": "11.0.5", "@nestjs/platform-express": "11.1.28", "@nestjs/schedule": "6.1.3", @@ -45,6 +49,7 @@ "@openrouter/ai-sdk-provider": "3.0.0", "@prisma/adapter-pg": "7.9.1", "@prisma/client": "7.9.1", + "@rekog/mcp-nest": "2.0.0", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", "ai": "7.0.37", @@ -69,6 +74,7 @@ "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "envalid": "8.2.0", + "express": "5.2.1", "fast-redact": "3.5.0", "fuse.js": "7.5.0", "google-spreadsheet": "3.2.0", @@ -8956,6 +8962,39 @@ "react": ">=16" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -8996,6 +9035,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -9603,6 +9655,64 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/microservices": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.28.tgz", + "integrity": "sha512-8uRs6/UrhXvd8YCrYKcNUwWA7b8jcbYH03WBKWJ2A3bc6+WcHA6yXq7Px30yemAGBtIIMXkHeL88dO2usVI5zg==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "amqp-connection-manager": "*", + "amqplib": "*", + "cache-manager": "*", + "ioredis": "*", + "kafkajs": "*", + "mqtt": "*", + "nats": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + }, + "amqp-connection-manager": { + "optional": true + }, + "amqplib": { + "optional": true + }, + "cache-manager": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "kafkajs": { + "optional": true + }, + "mqtt": { + "optional": true + }, + "nats": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", @@ -13475,6 +13585,43 @@ } } }, + "node_modules/@rekog/mcp-nest": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.0.tgz", + "integrity": "sha512-lT5V26V4fvPay50zMiw2KrEqlDSuzdAeyoQFd0+vfAwb40G6H8p8psTzgq5atTgNHktRPI7u4x8lrm8X844Asg==", + "license": "MIT", + "dependencies": { + "multer": "^2.2.0", + "path-to-regexp": "^8.4.2", + "rxjs": "^7.8.2" + }, + "peerDependencies": { + "@modelcontextprotocol/core": "^2.0.0-beta.5", + "@modelcontextprotocol/node": "^2.0.0-beta.5", + "@modelcontextprotocol/server": "^2.0.0-beta.5", + "@nestjs/common": ">=9.0.0", + "@nestjs/core": ">=9.0.0", + "@nestjs/microservices": ">=9.0.0", + "@nestjs/platform-fastify": "^11.1.5", + "express": ">=4.0.0", + "reflect-metadata": "^0.2.2", + "zod": "^4.3.5" + }, + "peerDependenciesMeta": { + "@nestjs/platform-fastify": { + "optional": true + } + } + }, + "node_modules/@rekog/mcp-nest/node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", diff --git a/package.json b/package.json index e0f6a2960..6e77e9cd9 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", @@ -81,6 +84,7 @@ "@nestjs/core": "11.1.28", "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", + "@nestjs/microservices": "11.1.28", "@nestjs/passport": "11.0.5", "@nestjs/platform-express": "11.1.28", "@nestjs/schedule": "6.1.3", @@ -89,6 +93,7 @@ "@openrouter/ai-sdk-provider": "3.0.0", "@prisma/adapter-pg": "7.9.1", "@prisma/client": "7.9.1", + "@rekog/mcp-nest": "2.0.0", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", "ai": "7.0.37", @@ -113,6 +118,7 @@ "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "envalid": "8.2.0", + "express": "5.2.1", "fast-redact": "3.5.0", "fuse.js": "7.5.0", "google-spreadsheet": "3.2.0", diff --git a/prisma/migrations/20260823120000_added_type_to_access/migration.sql b/prisma/migrations/20260823120000_added_type_to_access/migration.sql new file mode 100644 index 000000000..f5fc7b40c --- /dev/null +++ b/prisma/migrations/20260823120000_added_type_to_access/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "AccessType" AS ENUM ('MCP', 'PRIVATE', 'PUBLIC'); + +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "type" "AccessType" NOT NULL DEFAULT 'PRIVATE'; + +-- Derive the type from the grantee of the existing accesses +UPDATE "Access" +SET "type" = CASE + WHEN "granteeUserId" IS NULL THEN 'PUBLIC'::"AccessType" + ELSE 'PRIVATE'::"AccessType" +END; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 06ba23563..c0dd25a60 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,15 +10,16 @@ datasource db { model Access { alias String? - createdAt DateTime @default(now()) - granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) + createdAt DateTime @default(now()) + granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) granteeUserId String? - id String @id @default(uuid()) - scopes String[] @default([]) - settings Json @default("{}") - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + scopes String[] @default([]) + settings Json @default("{}") + type AccessType @default(PRIVATE) + updatedAt DateTime @updatedAt userId String - user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) + user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) @@index([alias]) @@index([granteeUserId]) @@ -331,6 +332,12 @@ model User { @@index([thirdPartyId]) } +enum AccessType { + MCP + PRIVATE + PUBLIC +} + enum AssetClass { ALTERNATIVE_INVESTMENT COMMODITY