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