Browse Source

Add MCP tool to get activities

pull/7742/head
Thomas Kaul 1 day ago
parent
commit
f88fe30c33
  1. 30
      apps/api/src/app/endpoints/ai/ai.service.ts
  2. 86
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 2
      apps/api/src/app/endpoints/mcp/mcp.module.ts

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

@ -98,10 +98,16 @@ export class AiService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public static getActivitiesTableColumnNames() { public static getActivitiesTableColumnNames({
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => { withValues
}: {
withValues: boolean;
}) {
return AiService.getActivitiesTableColumnDefinitions({ withValues }).map(
({ name }) => {
return name; return name;
}); }
);
} }
public static getHoldingsTableColumnNames() { public static getHoldingsTableColumnNames() {
@ -110,6 +116,18 @@ export class AiService {
}); });
} }
private static getActivitiesTableColumnDefinitions({
withValues
}: {
withValues: boolean;
}) {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter(
({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
}
);
}
public async generateText({ public async generateText({
prompt, prompt,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
@ -173,11 +191,7 @@ export class AiService {
}); });
const activitiesTableColumnDefinitions = const activitiesTableColumnDefinitions =
AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.filter( AiService.getActivitiesTableColumnDefinitions({ withValues });
({ requiresScopeToReadValues }) => {
return withValues || !requiresScopeToReadValues;
}
);
const activitiesTableRows = activities.map( const activitiesTableRows = activities.map(
({ ({

86
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -22,18 +22,23 @@ import { z } from 'zod';
const GET_ACTIVITIES_PARAMETERS = z.object({ const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z activityTypes: z
.array(z.enum(ActivityType)) .array(z.enum(ActivityType))
.min(1)
.optional() .optional()
.describe('The types of the activities to get'), .describe('The types of the activities to get'),
assetClasses: z assetClasses: z
.array(z.enum(AssetClass)) .array(z.enum(AssetClass))
.min(1)
.optional() .optional()
.describe('The asset classes of the activities to get'), .describe('The asset classes of the activities to get'),
holding: z
.object({
dataSource: z dataSource: z
.enum(DataSource) .enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
})
.optional() .optional()
.describe( .describe('The asset profile of the activities to get'),
'The data source of the asset profile, which only takes effect together with the symbol'
),
range: z range: z
.string() .string()
.regex(DATE_RANGE_PATTERN) .regex(DATE_RANGE_PATTERN)
@ -49,12 +54,6 @@ const GET_ACTIVITIES_PARAMETERS = z.object({
.min(0) .min(0)
.optional() .optional()
.describe('The number of activities to skip'), .describe('The number of activities to skip'),
symbol: z
.string()
.optional()
.describe(
'The symbol of the asset profile, which only takes effect together with the data source'
),
take: z take: z
.number() .number()
.int() .int()
@ -79,9 +78,11 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get activities' title: 'Get activities'
}, },
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().join( description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames(
{ withValues: false }
).join(
', ' ', '
)}. The columns with a monetary value are omitted if the access does not grant to read them. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, )}. More columns with a monetary value are added if the access grants to read them. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`,
name: 'get-activities', name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS parameters: GET_ACTIVITIES_PARAMETERS
}) })
@ -97,10 +98,9 @@ export class GhostfolioMcpController {
{ {
activityTypes, activityTypes,
assetClasses, assetClasses,
dataSource, holding,
range, range,
skip, skip,
symbol,
take take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> }: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) { ) {
@ -115,23 +115,55 @@ export class GhostfolioMcpController {
const filtersOfAccess = filters ?? []; const filtersOfAccess = filters ?? [];
const typesOfFiltersOfAccess = new Set( const filtersOfTool = this.apiService.buildFiltersFromQueryParams({
filtersOfAccess.map(({ type }) => { filterByAssetClasses: assetClasses?.join(','),
return type; filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
// A tool must never widen the access, hence a filter of the tool which
// the access does not permit gives no activity
const filtersOfToolOutsideAccess = filtersOfTool.filter(({ id, type }) => {
const filtersOfAccessOfType = filtersOfAccess.filter((filter) => {
return filter.type === type;
});
return (
filtersOfAccessOfType.length > 0 &&
!filtersOfAccessOfType.some((filter) => {
return filter.id === id;
}) })
); );
});
// A filter of the tool is dropped if the access already restricts its if (filtersOfToolOutsideAccess.length > 0) {
// type, because the filters of a type are combined with a logical or and const valuesOutsideAccess = filtersOfToolOutsideAccess
// a tool must never widen the access .map(({ id }) => {
const filtersOfTool = this.apiService return id;
.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: dataSource,
filterBySymbol: symbol
}) })
.filter(({ type }) => { .join(', ');
return !typesOfFiltersOfAccess.has(type);
return {
content: [
{
text: `No activities found. The access does not permit these values of the parameters: ${valuesOutsideAccess}.`,
type: 'text' as const
}
]
};
}
const typesOfFiltersOfTool = new Set(
filtersOfTool.map(({ type }) => {
return type;
})
);
// The filters of a type are combined with a logical or, hence a filter of
// the tool replaces the filters of the access of the same type instead of
// joining them
const filtersOfAccessOutsideTool = filtersOfAccess.filter(({ type }) => {
return !typesOfFiltersOfTool.has(type);
}); });
const table = await this.aiService.getActivitiesTable({ const table = await this.aiService.getActivitiesTable({
@ -139,7 +171,7 @@ export class GhostfolioMcpController {
skip, skip,
startDate, startDate,
userId, userId,
filters: [...filtersOfAccess, ...filtersOfTool], filters: [...filtersOfAccessOutsideTool, ...filtersOfTool],
take: take ?? MCP_MAX_ACTIVITIES, take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes, types: activityTypes,
userCurrency: userSettings.baseCurrency, userCurrency: userSettings.baseCurrency,

2
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -26,7 +26,7 @@ import { GhostfolioMcpController } from './mcp.controller';
return new McpStrategy({ return new McpStrategy({
instructions: instructions:
'Ghostfolio is a wealth management application. The tools read the portfolio of the user who granted the access. They give no monetary value.', '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).',
name: 'ghostfolio', name: 'ghostfolio',
title: 'Ghostfolio', title: 'Ghostfolio',
transports: [ transports: [

Loading…
Cancel
Save