mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
46 changed files with 1038 additions and 100 deletions
@ -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 }] }; |
|||
} |
|||
} |
|||
@ -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 {} |
|||
@ -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) |
|||
); |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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>(); |
|||
} |
|||
@ -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 +0,0 @@ |
|||
export type AccessType = 'PRIVATE' | 'PUBLIC'; |
|||
@ -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; |
|||
Loading…
Reference in new issue