Browse Source

Add MCP tool to import activities

pull/7766/head
Thomas Kaul 2 months ago
parent
commit
698cc1cf3c
  1. 4
      README.md
  2. 129
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 6
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 1
      apps/api/src/app/import/import.module.ts
  5. 17
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  6. 23
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  7. 26
      libs/common/src/lib/scopes.spec.ts
  8. 7
      libs/common/src/lib/scopes.ts

4
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

129
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<typeof IMPORT_ACTIVITIES_PARAMETERS>
) {
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 }] };
}
}

6
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: [

1
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,

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

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

@ -82,20 +82,17 @@
<mat-option value="READ_RESTRICTED">
<gf-access-level-icon accessLevel="READ_RESTRICTED" />
</mat-option>
@if (!isPublicAccess) {
<mat-option
value="CREATE_READ_RESTRICTED_UPDATE_DELETE"
[disabled]="!canGrantWriteAccess"
>
<gf-access-level-icon
accessLevel="CREATE_READ_RESTRICTED_UPDATE_DELETE"
/>
</mat-option>
}
@if (accessForm.get('type')?.value === 'PRIVATE') {
<!--
The permission to change the data without the monetary values is
not available yet, because it is intended for the model context
protocol, which exposes read tools only
<mat-option
value="CREATE_READ_RESTRICTED_UPDATE_DELETE"
[disabled]="!canGrantWriteAccess"
>
<gf-access-level-icon
accessLevel="CREATE_READ_RESTRICTED_UPDATE_DELETE"
/>
</mat-option>
-->
<mat-option value="READ">
<gf-access-level-icon accessLevel="READ" />
</mat-option>

26
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');
});
});

7
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<AccessType, readonly Scope[]> = {
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
};

Loading…
Cancel
Save