Browse Source

Feature/setup MCP (#7703)

* Setup MCP

* Update changelog
pull/7690/head
Thomas Kaul 5 days ago
committed by GitHub
parent
commit
ea4d66be54
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .gitignore
  2. 3
      CHANGELOG.md
  3. 22
      README.md
  4. 62
      apps/api/src/app/access/access.controller.ts
  5. 2
      apps/api/src/app/app.module.ts
  6. 6
      apps/api/src/app/endpoints/ai/ai.service.ts
  7. 42
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  8. 47
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  9. 4
      apps/api/src/app/endpoints/public/public.service.ts
  10. 4
      apps/api/src/app/info/info.service.ts
  11. 5
      apps/api/src/decorators/impersonation.decorator.ts
  12. 18
      apps/api/src/decorators/requires-scope-of-access.decorator.ts
  13. 62
      apps/api/src/filters/mcp-tool-exception.filter.ts
  14. 42
      apps/api/src/guards/access.guard.ts
  15. 10
      apps/api/src/guards/impersonation-write.guard.ts
  16. 7
      apps/api/src/guards/impersonation.guard.ts
  17. 5
      apps/api/src/guards/scope.guard.ts
  18. 18
      apps/api/src/helper/bearer-token.helper.ts
  19. 19
      apps/api/src/helper/execution-context.helper.ts
  20. 40
      apps/api/src/main.ts
  21. 61
      apps/api/src/middlewares/mcp-authorization.middleware.ts
  22. 1
      apps/api/src/services/configuration/configuration.service.ts
  23. 138
      apps/api/src/services/impersonation/impersonation.service.spec.ts
  24. 25
      apps/api/src/services/impersonation/impersonation.service.ts
  25. 1
      apps/api/src/services/interfaces/environment.interface.ts
  26. 24
      apps/client/src/app/components/access-table/access-table.component.html
  27. 25
      apps/client/src/app/components/access-table/access-table.component.ts
  28. 41
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  29. 16
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  30. 2
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  31. 3
      libs/common/src/lib/config.ts
  32. 14
      libs/common/src/lib/dtos/create-access.dto.ts
  33. 41
      libs/common/src/lib/helper.spec.ts
  34. 16
      libs/common/src/lib/helper.ts
  35. 2
      libs/common/src/lib/interfaces/access.interface.ts
  36. 1
      libs/common/src/lib/permissions.ts
  37. 79
      libs/common/src/lib/scopes.spec.ts
  38. 32
      libs/common/src/lib/scopes.ts
  39. 1
      libs/common/src/lib/types/access-type.type.ts
  40. 3
      libs/common/src/lib/types/impersonation-context.type.ts
  41. 2
      libs/common/src/lib/types/index.ts
  42. 1
      libs/common/src/lib/types/request-with-user.type.ts
  43. 147
      package-lock.json
  44. 6
      package.json
  45. 12
      prisma/migrations/20260823120000_added_type_to_access/migration.sql
  46. 7
      prisma/schema.prisma

1
.gitignore

@ -31,6 +31,7 @@ npm-debug.log
.env .env
.env.prod .env.prod
.github/instructions/nx.instructions.md .github/instructions/nx.instructions.md
.mcp.json
.nx/cache .nx/cache
.nx/migrate-runs .nx/migrate-runs
.nx/polygraph .nx/polygraph

3
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 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 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) - 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
- 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 - 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 ## 3.58.0 - 2026-08-22

22
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}` | | `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`) | | `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_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 | | `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) | | `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"]` | | `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 <INSERT_IDENTIFIER_OF_ACCESS>"
```
## Community Projects ## Community Projects
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio

62
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { isValidGranteeOfAccess } from '@ghostfolio/common/helper';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import { getScopesOfAccess } from '@ghostfolio/common/scopes';
@ -49,27 +50,15 @@ export class AccessController {
}); });
return accessesWithGranteeUser.map((accessItem) => { return accessesWithGranteeUser.map((accessItem) => {
const { alias, granteeUser, id, settings } = accessItem; const { alias, granteeUser, id, settings, type } = accessItem;
const scopes = getScopesOfAccess(accessItem);
if (granteeUser) {
return { return {
alias, alias,
id, id,
scopes, type,
grantee: granteeUser?.id, grantee: granteeUser?.id,
settings: settings as AccessSettings, scopes: getScopesOfAccess(accessItem),
type: 'PRIVATE' settings: settings as AccessSettings
};
}
return {
alias,
id,
scopes,
grantee: 'Public',
settings: settings as AccessSettings,
type: 'PUBLIC'
}; };
}); });
} }
@ -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 { try {
return await this.accessService.createAccess({ return await this.accessService.createAccess({
type,
alias: data.alias || undefined, alias: data.alias || undefined,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
scopes: getScopesOfAccess({ scopes: getScopesOfAccess({
granteeUserId: data.granteeUserId, type,
scopes: data.scopes scopes: data.scopes
}), }),
settings: this.accessService.buildSettings(data.filters), 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 { try {
return await this.accessService.updateAccess({ return await this.accessService.updateAccess({
data: { data: {
@ -172,8 +196,8 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
scopes: getScopesOfAccess({ scopes: getScopesOfAccess({
granteeUserId: data.granteeUserId, scopes: data.scopes ?? originalAccess.scopes,
scopes: data.scopes ?? originalAccess.scopes type: originalAccess.type
}), }),
settings: this.accessService.buildSettings(data.filters) settings: this.accessService.buildSettings(data.filters)
}, },

2
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 { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.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 { PlatformsModule } from './endpoints/platforms/platforms.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module'; import { SitemapModule } from './endpoints/sitemap/sitemap.module';
@ -128,6 +129,7 @@ import { UserModule } from './user/user.module';
InfoModule, InfoModule,
LogoModule, LogoModule,
MarketDataModule, MarketDataModule,
McpModule,
PlatformModule, PlatformModule,
PlatformsModule, PlatformsModule,
PortfolioModule, PortfolioModule,

6
apps/api/src/app/endpoints/ai/ai.service.ts

@ -48,6 +48,12 @@ export class AiService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public static getHoldingsTableColumnNames() {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public async generateText({ public async generateText({
prompt, prompt,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')

42
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 }] };
}
}

47
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 {}

4
apps/api/src/app/endpoints/public/public.service.ts

@ -36,8 +36,8 @@ export class PublicService {
accessId: string accessId: string
): Promise<PublicPortfolioResponse> { ): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ const access = await this.accessService.access({
granteeUserId: null, id: accessId,
id: accessId type: 'PUBLIC'
}); });
if (!access) { if (!access) {

4
apps/api/src/app/info/info.service.ts

@ -75,6 +75,10 @@ export class InfoService {
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_MCP')) {
globalPermissions.push(permissions.enableMcp);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = await this.propertyService.getByKey<boolean>( isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE PROPERTY_IS_READ_ONLY_MODE

5
apps/api/src/decorators/impersonation.decorator.ts

@ -1,3 +1,4 @@
import { getRequest } from '@ghostfolio/api/helper/execution-context.helper';
import type { import type {
ImpersonationContext, ImpersonationContext,
RequestWithUser RequestWithUser
@ -17,9 +18,7 @@ import {
*/ */
export const Impersonation = createParamDecorator( export const Impersonation = createParamDecorator(
(_data: unknown, context: ExecutionContext): ImpersonationContext => { (_data: unknown, context: ExecutionContext): ImpersonationContext => {
const { impersonation } = context const { impersonation } = getRequest<RequestWithUser>(context) ?? {};
.switchToHttp()
.getRequest<RequestWithUser>();
if (!impersonation) { if (!impersonation) {
throw new InternalServerErrorException( throw new InternalServerErrorException(

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

62
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<never> {
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;
}
}

42
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<RequestWithUser>(context);
if (!request?.impersonationOfBearerToken?.isActive) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
request.impersonation = request.impersonationOfBearerToken;
return true;
}
}

10
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 { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { SCOPES_OF_WRITE_ACCESS, Scope } from '@ghostfolio/common/scopes'; 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 constructor(private readonly reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean { public canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') { const request = getRequest<{
headers?: Record<string, string>;
method?: string;
}>(context);
if (!request) {
return true; return true;
} }
const request = context.switchToHttp().getRequest();
if (request.method === 'GET') { if (request.method === 'GET') {
return true; return true;
} }

7
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { import {
HEADER_KEY_IMPERSONATION, HEADER_KEY_IMPERSONATION,
@ -26,7 +27,11 @@ export class ImpersonationGuard implements CanActivate {
) {} ) {}
public async canActivate(context: ExecutionContext) { public async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<RequestWithUser>(); const request = getRequest<RequestWithUser>(context);
if (!request) {
return true;
}
const impersonationId = request.headers?.[ const impersonationId = request.headers?.[
HEADER_KEY_IMPERSONATION.toLowerCase() HEADER_KEY_IMPERSONATION.toLowerCase()

5
apps/api/src/guards/scope.guard.ts

@ -1,4 +1,5 @@
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; 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 { hasScope, Scope } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -30,9 +31,7 @@ export class ScopeGuard implements CanActivate {
return true; return true;
} }
const { impersonation } = context const { impersonation } = getRequest<RequestWithUser>(context) ?? {};
.switchToHttp()
.getRequest<RequestWithUser>();
const hasRequiredScopes = requiredScopes.every((scope) => { const hasRequiredScopes = requiredScopes.every((scope) => {
return hasScope(impersonation?.scopes, scope); return hasScope(impersonation?.scopes, scope);

18
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;
}

19
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<T>(context: ExecutionContext): T | undefined {
if (context.getType() === 'http') {
return context.switchToHttp().getRequest<T>();
}
const contextOfMessage = context.switchToRpc().getContext<{
getRawRequest?: <U>() => U | undefined;
}>();
return contextOfMessage?.getRawRequest?.<T>();
}

40
apps/api/src/main.ts

@ -1,9 +1,12 @@
import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
MCP_ENDPOINT,
STORYBOOK_PATH, STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -17,6 +20,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { MCP_STRATEGY, McpStrategy } from '@rekog/mcp-nest';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
@ -132,6 +136,42 @@ async function bootstrap() {
const HOST = configService.get<string>('HOST') || DEFAULT_HOST; const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT; const PORT = configService.get<number>('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<McpStrategy>(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, () => { await app.listen(PORT, HOST, () => {
logLogo(); logLogo();

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

1
apps/api/src/services/configuration/configuration.service.ts

@ -72,6 +72,7 @@ export class ConfigurationService {
ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_MCP: bool({ default: false }),
ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),

138
apps/api/src/services/impersonation/impersonation.service.spec.ts

@ -50,8 +50,35 @@ describe('Impersonation service', () => {
const prismaService = { const prismaService = {
access: { access: {
findFirst: async () => { findFirst: async ({
return access ?? null; 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: { user: {
@ -104,8 +131,8 @@ describe('Impersonation service', () => {
const grantedAccess = { const grantedAccess = {
granteeUserId: authenticatedUserId, granteeUserId: authenticatedUserId,
id: accessId, id: accessId,
permissions: ['READ'],
scopes: [scopes.portfolioRead], scopes: [scopes.portfolioRead],
type: 'PRIVATE',
userId: impersonatedUserId userId: impersonatedUserId
} as unknown as Access; } 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 // The guard rejects the request in this case, hence the context must not
// present the data of the authenticated user as impersonated data // present the data of the authenticated user as impersonated data
describe('With an identifier which cannot be resolved', () => { describe('With an identifier which cannot be resolved', () => {

25
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; 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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
getScopesOfAccess, getScopesOfAccess,
@ -15,7 +15,7 @@ import type {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Access } from '@prisma/client'; import { Access, AccessType } from '@prisma/client';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
@ -25,15 +25,23 @@ export class ImpersonationService {
private readonly subscriptionService: SubscriptionService 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({ public async resolve({
impersonationId, impersonationId,
types,
user user
}: { }: {
impersonationId?: string; impersonationId?: string;
types?: AccessType[];
user?: UserWithSettings; user?: UserWithSettings;
}): Promise<ImpersonationContext> { }): Promise<ImpersonationContext> {
const { access, userId: impersonatedUserId } = const { access, userId: impersonatedUserId } =
await this.validateImpersonation({ impersonationId, user }); await this.validateImpersonation({ impersonationId, types, user });
if (!impersonatedUserId) { if (!impersonatedUserId) {
return { return {
@ -55,9 +63,11 @@ export class ImpersonationService {
where: { id: impersonatedUserId } where: { id: impersonatedUserId }
}); });
const { filters } = (access?.settings ?? {}) as AccessSettings;
const settings = impersonatedUser?.settings?.settings as UserSettings; const settings = impersonatedUser?.settings?.settings as UserSettings;
return { return {
filters,
accessId: impersonationId, accessId: impersonationId,
authenticatedUserSubscription: user?.subscription, authenticatedUserSubscription: user?.subscription,
isActive: true, isActive: true,
@ -83,9 +93,11 @@ export class ImpersonationService {
private async validateImpersonation({ private async validateImpersonation({
impersonationId, impersonationId,
types,
user user
}: { }: {
impersonationId?: string; impersonationId?: string;
types?: AccessType[];
user?: UserWithSettings; user?: UserWithSettings;
}): Promise<{ access?: Access; userId: string | null }> { }): Promise<{ access?: Access; userId: string | null }> {
if (!impersonationId) { if (!impersonationId) {
@ -113,12 +125,11 @@ export class ImpersonationService {
return { userId: impersonatedUser?.id ?? null }; return { userId: impersonatedUser?.id ?? null };
} }
} else { } else if (types?.length) {
// Public access
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: null, id: impersonationId,
user: { id: impersonationId } type: { in: types }
} }
}); });

1
apps/api/src/services/interfaces/environment.interface.ts

@ -23,6 +23,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CRON: boolean; ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_MCP: boolean;
ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_RATE_LIMITING: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;

24
apps/client/src/app/components/access-table/access-table.component.html

@ -10,7 +10,13 @@
<ng-container matColumnDef="grantee"> <ng-container matColumnDef="grantee">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.grantee) {
{{ element.grantee }} {{ element.grantee }}
} @else if (element.type === 'PUBLIC') {
<span i18n>Public</span>
} @else if (element.type === 'MCP') {
<span>MCP</span>
}
</td> </td>
</ng-container> </ng-container>
@ -40,6 +46,13 @@
> >
</div> </div>
} }
} @else if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<div>
<code>{{ baseUrl }}{{ mcpEndpoint }}</code>
</div>
<div>
<code>Authorization: Bearer {{ element.id }}</code>
</div>
} }
</td> </td>
</ng-container> </ng-container>
@ -79,9 +92,18 @@
</span> </span>
</button> </button>
} }
@if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<button mat-menu-item (click)="onCopyIdToClipboard(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Copy identifier to clipboard</span>
</span>
</button>
}
@if ( @if (
(!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) || (!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) ||
element.type === 'PUBLIC' element.type === 'PUBLIC' ||
(element.type === 'MCP' && hasPermissionToEnableMcp)
) { ) {
<hr class="my-0" /> <hr class="my-0" />
} }

25
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 { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { getAccessLevel } from '@ghostfolio/common/scopes'; import { getAccessLevel } from '@ghostfolio/common/scopes';
import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon'; import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { import {
@ -74,11 +77,16 @@ export class GfAccessTableComponent {
protected readonly getAccessLevel = getAccessLevel; protected readonly getAccessLevel = getAccessLevel;
protected hasPermissionToEnableMcp = false;
protected readonly isLoading = computed(() => { protected readonly isLoading = computed(() => {
return !this.accesses(); return !this.accesses();
}); });
protected readonly mcpEndpoint = MCP_ENDPOINT;
private readonly clipboard = inject(Clipboard); private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
@ -91,6 +99,11 @@ export class GfAccessTableComponent {
removeCircleOutline removeCircleOutline
}); });
this.hasPermissionToEnableMcp = hasPermission(
this.dataService.fetchInfo().globalPermissions,
permissions.enableMcp
);
effect(() => { effect(() => {
this.dataSource.data = this.accesses() ?? []; this.dataSource.data = this.accesses() ?? [];
}); });
@ -102,6 +115,18 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; 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) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));

41
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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
Scope, Scope,
getAccessLevel, getAccessLevel,
@ -81,6 +82,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
protected readonly mode: 'create' | 'update'; protected readonly mode: 'create' | 'update';
private hasExperimentalFeatures = false; private hasExperimentalFeatures = false;
private hasPermissionToEnableMcp = false;
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
@ -102,10 +104,11 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
public get canApplyFilters() { public get canApplyFilters() {
return ( return this.isPublicAccess && this.hasExperimentalFeatures;
this.accessForm?.get('type')?.value === 'PUBLIC' && }
this.hasExperimentalFeatures
); public get canGrantMcpAccess() {
return this.hasExperimentalFeatures && this.hasPermissionToEnableMcp;
} }
public get canGrantWriteAccess() { public get canGrantWriteAccess() {
@ -114,15 +117,22 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
const access = this.data?.access; 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({ this.accessForm = this.formBuilder.group({
accessLevel: getAccessLevel(access?.scopes), accessLevel: getAccessLevel(access?.scopes),
alias: [access?.alias ?? ''], alias: [access?.alias ?? ''],
filters: [null], filters: [null],
granteeUserId: [ granteeUserId: [
access?.grantee ?? null, isPrivate ? (access?.grantee ?? null) : null,
isPublic ? null : Validators.required isPrivate ? Validators.required : null
], ],
type: [ type: [
{ disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' }, { disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' },
@ -151,16 +161,20 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl?.setValidators(Validators.required); granteeUserIdControl?.setValidators(Validators.required);
this.accessForm.get('filters')?.setValue(null);
} else { } else {
granteeUserIdControl?.clearValidators(); granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null); granteeUserIdControl?.setValue(null);
// A public access never exposes the monetary values and never // An access which is not granted to a user never exposes the
// changes data // monetary values and never changes data
this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED'); 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(); granteeUserIdControl?.updateValueAndValidity();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -173,6 +187,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return this.accessForm?.get('accessLevel')?.value as AccessLevel; return this.accessForm?.get('accessLevel')?.value as AccessLevel;
} }
protected get isPublicAccess() {
return this.accessForm?.get('type')?.value === 'PUBLIC';
}
protected onCancel() { protected onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -213,7 +231,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
filters: filters.length > 0 ? filters : undefined, filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
scopes: this.buildScopes() scopes: this.buildScopes(),
type: this.accessForm.get('type')?.value
}; };
try { try {

16
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -23,13 +23,25 @@
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field
appearance="outline"
class="w-100"
[class.without-hint]="!isPublicAccess"
>
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-option i18n value="PRIVATE">Private</mat-option> <mat-option i18n value="PRIVATE">Private</mat-option>
<mat-option i18n value="PUBLIC">Public</mat-option> <mat-option i18n value="PUBLIC">Public</mat-option>
@if (canGrantMcpAccess) {
<mat-option value="MCP">Model Context Protocol (MCP)</mat-option>
}
</mat-select> </mat-select>
@if (isPublicAccess) {
<mat-hint i18n
>Any person who has the link can view the data.</mat-hint
>
}
</mat-form-field> </mat-form-field>
</div> </div>

2
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -268,7 +268,7 @@ export class GfUserAccountAccessComponent implements OnInit {
data: { data: {
access: { access: {
alias: access.alias, alias: access.alias,
grantee: access.grantee === 'Public' ? undefined : access.grantee, grantee: access.grantee,
id: access.id, id: access.id,
scopes: access.scopes, scopes: access.scopes,
settings: access.settings, settings: access.settings,

3
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 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_3_FIGURES = 100;
export const NUMERICAL_PRECISION_THRESHOLD_4_FIGURES = 1000; export const NUMERICAL_PRECISION_THRESHOLD_4_FIGURES = 1000;
export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000; export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000;

14
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,7 +1,15 @@
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Scope, scopes } from '@ghostfolio/common/scopes'; 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 { export class CreateAccessDto {
@IsOptional() @IsOptional()
@ -20,4 +28,8 @@ export class CreateAccessDto {
@IsIn(Object.values(scopes), { each: true }) @IsIn(Object.values(scopes), { each: true })
@IsOptional() @IsOptional()
scopes?: Scope[]; scopes?: Scope[];
@IsEnum(AccessType)
@IsOptional()
type?: AccessType;
} }

41
libs/common/src/lib/helper.spec.ts

@ -13,6 +13,7 @@ import {
isCurrencySymbol, isCurrencySymbol,
isSplitRatio, isSplitRatio,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
isValidGranteeOfAccess,
resolveUserSettings resolveUserSettings
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UserSettings } from '@ghostfolio/common/interfaces'; 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', () => { describe('Resolve user settings', () => {
const userSettings: UserSettings = { const userSettings: UserSettings = {
baseCurrency: 'CHF', baseCurrency: 'CHF',

16
libs/common/src/lib/helper.ts

@ -1,5 +1,6 @@
import { NumberParser } from '@internationalized/number'; import { NumberParser } from '@internationalized/number';
import { import {
AccessType,
Type as ActivityType, Type as ActivityType,
AssetProfileOverrides, AssetProfileOverrides,
AssetSubClass, AssetSubClass,
@ -654,6 +655,21 @@ export function isValidCustomAssetProfileSymbol(aSymbol: string) {
return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); 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) { export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
} }

2
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'; import { AccessSettings } from './access-settings.interface';

1
libs/common/src/lib/permissions.ts

@ -41,6 +41,7 @@ export const permissions = {
enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableDataProviderGhostfolio: 'enableDataProviderGhostfolio',
enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableFearAndGreedIndex: 'enableFearAndGreedIndex',
enableImport: 'enableImport', enableImport: 'enableImport',
enableMcp: 'enableMcp',
enableBlog: 'enableBlog', enableBlog: 'enableBlog',
enableStatistics: 'enableStatistics', enableStatistics: 'enableStatistics',
enableSubscription: 'enableSubscription', enableSubscription: 'enableSubscription',

79
libs/common/src/lib/scopes.spec.ts

@ -1,4 +1,5 @@
import { import {
Scope,
SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS, SCOPES_OF_WRITE_ACCESS,
@ -60,7 +61,7 @@ describe('Scopes', () => {
it('Gives the scopes of the access', () => { it('Gives the scopes of the access', () => {
expect( expect(
getScopesOfAccess({ getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', type: 'PRIVATE',
scopes: [scopes.portfolioRead, scopes.portfolioReadValues] scopes: [scopes.portfolioRead, scopes.portfolioReadValues]
}) })
).toEqual([scopes.portfolioRead, scopes.portfolioReadValues]); ).toEqual([scopes.portfolioRead, scopes.portfolioReadValues]);
@ -69,23 +70,19 @@ describe('Scopes', () => {
it('Without the scope to read the values', () => { it('Without the scope to read the values', () => {
expect( expect(
getScopesOfAccess({ getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', scopes: [scopes.portfolioRead],
scopes: [scopes.portfolioRead] type: 'PRIVATE'
}) })
).not.toContain(scopes.portfolioReadValues); ).not.toContain(scopes.portfolioReadValues);
}); });
it('Without scopes', () => { it('Without scopes', () => {
expect( expect(getScopesOfAccess({ type: 'PRIVATE' })).toEqual([]);
getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d'
})
).toEqual([]);
}); });
it('Gives the write scopes', () => { it('Gives the write scopes', () => {
const scopesOfAccess = getScopesOfAccess({ const scopesOfAccess = getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', type: 'PRIVATE',
scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS] scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS]
}); });
@ -97,7 +94,7 @@ describe('Scopes', () => {
it('Drops an unknown scope', () => { it('Drops an unknown scope', () => {
expect( expect(
getScopesOfAccess({ getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', type: 'PRIVATE',
scopes: [scopes.portfolioRead, 'portfolio:write'] scopes: [scopes.portfolioRead, 'portfolio:write']
}) })
).toEqual([scopes.portfolioRead]); ).toEqual([scopes.portfolioRead]);
@ -107,13 +104,17 @@ describe('Scopes', () => {
describe('Get scopes of public access', () => { describe('Get scopes of public access', () => {
it('Allows reading the portfolio', () => { it('Allows reading the portfolio', () => {
expect( expect(
getScopesOfAccess({ scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS] }) getScopesOfAccess({
scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS],
type: 'PUBLIC'
})
).toContain(scopes.portfolioRead); ).toContain(scopes.portfolioRead);
}); });
it('Excludes the accounts and the watchlist', () => { it('Excludes the accounts and the watchlist', () => {
const scopesOfAccess = getScopesOfAccess({ const scopesOfAccess = getScopesOfAccess({
scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS] scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS],
type: 'PUBLIC'
}); });
expect(scopesOfAccess).not.toContain(scopes.accountRead); expect(scopesOfAccess).not.toContain(scopes.accountRead);
@ -127,14 +128,18 @@ describe('Scopes', () => {
scopes.portfolioRead, scopes.portfolioRead,
scopes.portfolioReadValues, scopes.portfolioReadValues,
scopes.watchlistRead scopes.watchlistRead
] ],
type: 'PUBLIC'
}) })
).toEqual([scopes.portfolioRead]); ).toEqual([scopes.portfolioRead]);
}); });
it('Cannot expose the monetary values', () => { it('Cannot expose the monetary values', () => {
expect( expect(
getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS] }) getScopesOfAccess({
scopes: [...SCOPES_OF_READ_ACCESS],
type: 'PUBLIC'
})
).not.toContain(scopes.portfolioReadValues); ).not.toContain(scopes.portfolioReadValues);
}); });
@ -142,7 +147,51 @@ describe('Scopes', () => {
// function is the sole barrier for a public access // function is the sole barrier for a public access
it('Gives no write scope', () => { it('Gives no write scope', () => {
expect( 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([]); ).toEqual([]);
}); });
}); });

32
libs/common/src/lib/scopes.ts

@ -1,5 +1,7 @@
import { AccessLevel } from '@ghostfolio/common/types'; 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 * 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 * 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; 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<AccessType, readonly Scope[]> = {
MCP: SCOPES_OF_READ_RESTRICTED_ACCESS,
PRIVATE: Object.values(scopes),
PUBLIC: SCOPES_OF_PUBLIC_ACCESS
};
/** /**
* Access level which the scopes of an access grant * Access level which the scopes of an access grant
*/ */
@ -73,25 +86,16 @@ export function getAccessLevel(aScopes: string[] = []): AccessLevel {
} }
export function getScopesOfAccess({ export function getScopesOfAccess({
granteeUserId, scopes: scopesOfAccess,
scopes: scopesOfAccess type
}: { }: {
granteeUserId?: string | null;
scopes?: string[]; scopes?: string[];
type: AccessType;
}): string[] { }): string[] {
const scopesToEvaluate = scopesOfAccess ?? []; const scopesToEvaluate = scopesOfAccess ?? [];
if (granteeUserId) { // An unknown scope is dropped
// An unknown scope is dropped, so that a scope which has been removed from return SCOPES_OF_TYPE[type].filter((scope) => {
// 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) => {
return scopesToEvaluate.includes(scope); return scopesToEvaluate.includes(scope);
}); });
} }

1
libs/common/src/lib/types/access-type.type.ts

@ -1 +0,0 @@
export type AccessType = 'PRIVATE' | 'PUBLIC';

3
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'; import { UserWithSettings } from '@ghostfolio/common/types';
/** /**
@ -10,6 +10,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
export interface ImpersonationContext { export interface ImpersonationContext {
accessId?: string; accessId?: string;
authenticatedUserSubscription?: UserWithSettings['subscription']; authenticatedUserSubscription?: UserWithSettings['subscription'];
filters?: Filter[];
isActive: boolean; isActive: boolean;
scopes: string[]; scopes: string[];
userId: string; userId: string;

2
libs/common/src/lib/types/index.ts

@ -1,5 +1,4 @@
import type { AccessLevel } from './access-level.type'; 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 { AccessWithGranteeUser } from './access-with-grantee-user.type';
import type { AccountWithBalance } from './account-with-balance.type'; import type { AccountWithBalance } from './account-with-balance.type';
import type { AccountWithPlatform } from './account-with-platform.type'; import type { AccountWithPlatform } from './account-with-platform.type';
@ -31,7 +30,6 @@ import type { ViewMode } from './view-mode.type';
export type { export type {
AccessLevel, AccessLevel,
AccessType,
AccessWithGranteeUser, AccessWithGranteeUser,
AccountWithBalance, AccountWithBalance,
AccountWithPlatform, AccountWithPlatform,

1
libs/common/src/lib/types/request-with-user.type.ts

@ -5,5 +5,6 @@ import {
export type RequestWithUser = Request & { export type RequestWithUser = Request & {
impersonation?: ImpersonationContext; impersonation?: ImpersonationContext;
impersonationOfBearerToken?: ImpersonationContext;
user: UserWithSettings; user: UserWithSettings;
}; };

147
package-lock.json

@ -29,6 +29,9 @@
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
"@ionic/angular": "8.8.12", "@ionic/angular": "8.8.12",
"@keyv/redis": "5.1.6", "@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", "@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.4", "@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
@ -37,6 +40,7 @@
"@nestjs/core": "11.1.28", "@nestjs/core": "11.1.28",
"@nestjs/event-emitter": "3.1.0", "@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/microservices": "11.1.28",
"@nestjs/passport": "11.0.5", "@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.28", "@nestjs/platform-express": "11.1.28",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
@ -45,6 +49,7 @@
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.9.1", "@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1", "@prisma/client": "7.9.1",
"@rekog/mcp-nest": "2.0.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -69,6 +74,7 @@
"dotenv": "17.4.2", "dotenv": "17.4.2",
"dotenv-expand": "13.0.0", "dotenv-expand": "13.0.0",
"envalid": "8.2.0", "envalid": "8.2.0",
"express": "5.2.1",
"fast-redact": "3.5.0", "fast-redact": "3.5.0",
"fuse.js": "7.5.0", "fuse.js": "7.5.0",
"google-spreadsheet": "3.2.0", "google-spreadsheet": "3.2.0",
@ -8956,6 +8962,39 @@
"react": ">=16" "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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", "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": { "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", "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" "@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": { "node_modules/@nestjs/passport": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz",

6
package.json

@ -73,6 +73,9 @@
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
"@ionic/angular": "8.8.12", "@ionic/angular": "8.8.12",
"@keyv/redis": "5.1.6", "@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", "@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.4", "@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
@ -81,6 +84,7 @@
"@nestjs/core": "11.1.28", "@nestjs/core": "11.1.28",
"@nestjs/event-emitter": "3.1.0", "@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/microservices": "11.1.28",
"@nestjs/passport": "11.0.5", "@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.28", "@nestjs/platform-express": "11.1.28",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
@ -89,6 +93,7 @@
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.9.1", "@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1", "@prisma/client": "7.9.1",
"@rekog/mcp-nest": "2.0.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -113,6 +118,7 @@
"dotenv": "17.4.2", "dotenv": "17.4.2",
"dotenv-expand": "13.0.0", "dotenv-expand": "13.0.0",
"envalid": "8.2.0", "envalid": "8.2.0",
"express": "5.2.1",
"fast-redact": "3.5.0", "fast-redact": "3.5.0",
"fuse.js": "7.5.0", "fuse.js": "7.5.0",
"google-spreadsheet": "3.2.0", "google-spreadsheet": "3.2.0",

12
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;

7
prisma/schema.prisma

@ -16,6 +16,7 @@ model Access {
id String @id @default(uuid()) id String @id @default(uuid())
scopes String[] @default([]) scopes String[] @default([])
settings Json @default("{}") settings Json @default("{}")
type AccessType @default(PRIVATE)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
userId String userId String
user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id])
@ -331,6 +332,12 @@ model User {
@@index([thirdPartyId]) @@index([thirdPartyId])
} }
enum AccessType {
MCP
PRIVATE
PUBLIC
}
enum AssetClass { enum AssetClass {
ALTERNATIVE_INVESTMENT ALTERNATIVE_INVESTMENT
COMMODITY COMMODITY

Loading…
Cancel
Save