From 698cc1cf3c0a1c4bfa60473ccafc1d1d7d8c240d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:46:32 +0200 Subject: [PATCH] Add MCP tool to import activities --- README.md | 4 +- .../src/app/endpoints/mcp/mcp.controller.ts | 129 +++++++++++++++++- apps/api/src/app/endpoints/mcp/mcp.module.ts | 6 +- apps/api/src/app/import/import.module.ts | 1 + ...reate-or-update-access-dialog.component.ts | 17 ++- .../create-or-update-access-dialog.html | 23 ++-- libs/common/src/lib/scopes.spec.ts | 26 +++- libs/common/src/lib/scopes.ts | 7 +- 8 files changed, 179 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index cb69cdc644..7c6f5ee7ad 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ 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. +The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio and import activities into it. ### Prerequisites @@ -366,7 +366,7 @@ The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio. - 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. +An _MCP_ access has (restricted) read scopes and never reads the monetary values. Grant the _Restricted view and manage_ permission to let the client also import activities. ### Connect a client diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index f8261f5c05..1358837d16 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -1,9 +1,12 @@ import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; +import { ImportService } from '@ghostfolio/api/app/import/import.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATE_RANGES, @@ -11,13 +14,19 @@ import { MCP_MAX_ACCOUNTS, MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { isCurrency } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; -import { UseFilters } from '@nestjs/common'; -import { Payload } from '@nestjs/microservices'; +import { HttpException, UseFilters } from '@nestjs/common'; +import { Payload, RpcException } from '@nestjs/microservices'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { McpController, Tool } from '@rekog/mcp-nest'; +import { isValid, parseISO } from 'date-fns'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { z } from 'zod'; const GET_ACCOUNTS_PARAMETERS = z.object({ @@ -89,12 +98,58 @@ const GET_ACTIVITIES_PARAMETERS = z.object({ .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) }); +const IMPORT_ACTIVITIES_PARAMETERS = z.object({ + activities: z + .array( + z.object({ + accountId: z + .string() + .optional() + .describe('The identifier of the account of the activity'), + comment: z.string().optional().describe('The comment of the activity'), + currency: z + .string() + .refine(isCurrency) + .describe( + 'The currency of the fee and of the unit price, as an ISO 4217 code' + ), + dataSource: z + .enum(DataSource) + .optional() + .describe('The data source of the asset profile'), + date: z + .string() + .refine((date) => { + return isValid(parseISO(date)); + }) + .describe( + 'The date of the activity, as an ISO 8601 date or date and time' + ), + fee: z.number().min(0).describe('The fee of the activity'), + quantity: z.number().min(0).describe('The quantity of the activity'), + symbol: z.string().describe('The symbol of the asset profile'), + tags: z + .array(z.string()) + .optional() + .describe('The identifiers of the tags of the activity'), + type: z.enum(ActivityType).describe('The type of the activity'), + unitPrice: z.number().min(0).describe('The unit price of the activity') + }) + ) + .min(1) + .max(MCP_MAX_ACTIVITIES) + .describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`) +}); + @McpController() @UseFilters(McpToolExceptionFilter) export class GhostfolioMcpController { public constructor( private readonly aiService: AiService, - private readonly apiService: ApiService + private readonly apiService: ApiService, + private readonly configurationService: ConfigurationService, + private readonly importService: ImportService, + private readonly userService: UserService ) {} @RequiresScopeOfAccess(scopes.accountRead) @@ -210,4 +265,72 @@ export class GhostfolioMcpController { return { content: [{ text: prompt, type: 'text' as const }] }; } + + @RequiresScopeOfAccess(scopes.activityCreate) + @Tool({ + annotations: { + destructiveHint: false, + openWorldHint: false, + readOnlyHint: false, + title: 'Import activities' + }, + description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the activities which are skipped, because an equal activity exists already. At most ${MCP_MAX_ACTIVITIES} activities are imported per call.`, + name: 'import-activities', + parameters: IMPORT_ACTIVITIES_PARAMETERS + }) + public async importActivities( + @Impersonation() { userId }: ImpersonationContext, + @Payload() { activities }: z.infer + ) { + const user = await this.userService.user({ id: userId }); + + if (!hasPermission(user?.permissions, permissions.createActivity)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let maxActivitiesToImport = this.configurationService.get( + 'MAX_ACTIVITIES_TO_IMPORT' + ); + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user.subscription?.type === SubscriptionType.Premium + ) { + maxActivitiesToImport = Number.MAX_SAFE_INTEGER; + } + + let importedActivities: Activity[]; + + try { + importedActivities = await this.importService.import({ + maxActivitiesToImport, + user, + accountsWithBalancesDto: [], + activitiesDto: activities, + assetProfilesWithMarketDataDto: [], + platformsDto: [], + tagsDto: [] + }); + } catch (error) { + // The message of the import names the activity which is not valid and + // is written for the caller, hence it is passed on + throw new RpcException( + error instanceof Error + ? error.message + : getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR) + ); + } + + const text = [ + `Imported activities: ${importedActivities.length}`, + `Skipped duplicate activities: ${ + activities.length - importedActivities.length + }` + ].join('\n'); + + return { content: [{ text, type: 'text' as const }] }; + } } diff --git a/apps/api/src/app/endpoints/mcp/mcp.module.ts b/apps/api/src/app/endpoints/mcp/mcp.module.ts index a29fae6191..e2fbce79f3 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.module.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.module.ts @@ -1,4 +1,6 @@ import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; +import { ImportModule } from '@ghostfolio/api/app/import/import.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { environment } from '@ghostfolio/api/environments/environment'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; @@ -16,7 +18,7 @@ import { GhostfolioMcpController } from './mcp.controller'; @Module({ controllers: [GhostfolioMcpController], - imports: [AiModule, ApiModule, ConfigurationModule], + imports: [AiModule, ApiModule, ConfigurationModule, ImportModule, UserModule], providers: [ { inject: [ConfigurationService], @@ -26,7 +28,7 @@ import { GhostfolioMcpController } from './mcp.controller'; 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 quantity and no monetary value (except the unit price of an activity).', + 'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access and import activities into it. They give no quantity and no monetary value (except the unit price of an activity).', name: 'ghostfolio', title: 'Ghostfolio', transports: [ diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 8aebcfa082..1a04e7505b 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -23,6 +23,7 @@ import { ImportService } from './import.service'; @Module({ controllers: [ImportController], + exports: [ImportService], imports: [ AccountModule, ActivitiesModule, diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 82924b81fc..b0be5c0c52 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -7,6 +7,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Scope, getAccessLevel, + getScopesOfAccess, getScopesOfAccessLevel, hasScope, scopes @@ -190,12 +191,20 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); - - // An access which is not granted to a user never exposes the - // monetary values and never changes data - this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED'); } + // Narrow the permission to the scopes which the type permits, because + // an access which is not granted to a user never exposes the monetary + // values and a public access never changes data + this.accessForm.get('accessLevel')?.setValue( + getAccessLevel( + getScopesOfAccess({ + scopes: getScopesOfAccessLevel(this.accessLevel), + type: accessType + }) + ) + ); + if (!canApplyFiltersToAccess({ type: accessType })) { this.accessForm.get('filters')?.setValue(null); } diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index b2957f9c29..89893e5708 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -82,20 +82,17 @@ + @if (!isPublicAccess) { + + + + } @if (accessForm.get('type')?.value === 'PRIVATE') { - diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index 1f8bb07b2b..877c5aa719 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -207,8 +207,8 @@ describe('Scopes', () => { ).not.toContain(scopes.portfolioReadValues); }); - // The dialog offers the write scopes for a private access only, hence this - // function is the sole barrier for a public access + // The dialog offers no permission which changes data for a public access, + // hence this function is the sole barrier for it it('Gives no write scope', () => { expect( getScopesOfAccess({ @@ -248,7 +248,9 @@ describe('Scopes', () => { ).not.toContain(scopes.portfolioReadValues); }); - it('Gives no write scope', () => { + // The controller exposes the tool to import activities as the only tool + // which writes, hence a further write scope stays ineffective + it('Gives the write scope to create an activity only', () => { expect( getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS], @@ -256,7 +258,23 @@ describe('Scopes', () => { }).filter((scope) => { return SCOPES_OF_WRITE_ACCESS.includes(scope as Scope); }) - ).toEqual([]); + ).toEqual([scopes.activityCreate]); + }); + + // The dialog narrows the access level to the scopes which the type + // permits, hence the restricted access level with the write scopes has to + // survive the intersection + it('Keeps the access level to change the data without the monetary values', () => { + expect( + getAccessLevel( + getScopesOfAccess({ + scopes: getScopesOfAccessLevel( + 'CREATE_READ_RESTRICTED_UPDATE_DELETE' + ), + type: 'MCP' + }) + ) + ).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE'); }); }); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index 27cba0e722..18270c7886 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -67,12 +67,7 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = * ineffective even if it is stored. */ const SCOPES_OF_TYPE: Record = { - MCP: [ - ...SCOPES_OF_READ_RESTRICTED_ACCESS - // Write scope is not permitted yet, because the controller exposes read - // tools only - // ...SCOPES_OF_WRITE_ACCESS - ], + MCP: [...SCOPES_OF_READ_RESTRICTED_ACCESS, scopes.activityCreate], PRIVATE: Object.values(scopes), PUBLIC: SCOPES_OF_PUBLIC_ACCESS };