Browse Source

Add MCP tool to search asset profiles

pull/7849/head
Thomas Kaul 1 month ago
parent
commit
656eb6e71b
  1. 6
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 24
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 2
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 35
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  5. 12
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  6. 87
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  7. 60
      apps/api/src/app/endpoints/mcp/mcp.service.ts

6
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -77,6 +77,12 @@ describe('GhostfolioMcpController', () => {
).toEqual([scopes.activityCreate]); ).toEqual([scopes.activityCreate]);
}); });
it('Requires the scope to create an activity for the tool to search asset profiles', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'searchAssetProfiles')
).toEqual([scopes.activityCreate]);
});
// The tools have no try and catch, hence the filter is the only guarantee // The tools have no try and catch, hence the filter is the only guarantee
// that an unexpected exception does not expose internals // that an unexpected exception does not expose internals
it('Applies the filter of the exceptions of the tools', () => { it('Applies the filter of the exceptions of the tools', () => {

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

@ -15,7 +15,8 @@ import 'zod/compile';
import { import {
GET_ACCOUNTS_PARAMETERS, GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
import { McpService } from './mcp.service'; import { McpService } from './mcp.service';
@ -98,7 +99,7 @@ export class GhostfolioMcpController {
readOnlyHint: false, readOnlyHint: false,
title: 'Import activities' title: 'Import activities'
}, },
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`,
name: 'import-activities', name: 'import-activities',
parameters: IMPORT_ACTIVITIES_PARAMETERS parameters: IMPORT_ACTIVITIES_PARAMETERS
}) })
@ -108,4 +109,23 @@ export class GhostfolioMcpController {
) { ) {
return this.mcpService.importActivities({ ...parameters, userId }); return this.mcpService.importActivities({ ...parameters, userId });
} }
@RequiresScopeOfAccess(scopes.activityCreate)
@Tool({
annotations: {
openWorldHint: true,
readOnlyHint: true,
title: 'Search asset profiles'
},
description:
'Searches for asset profiles available to the user and gives import-ready candidates. Use this before importing an activity unless the exact symbol and data source are already known. Select the candidate that matches the intended asset and pass its symbol, dataSource and currency unchanged to import-activities.',
name: 'search-asset-profiles',
parameters: SEARCH_ASSET_PROFILES_PARAMETERS
})
public async searchAssetProfiles(
@Impersonation() { userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof SEARCH_ASSET_PROFILES_PARAMETERS>
) {
return this.mcpService.searchAssetProfiles({ ...parameters, userId });
}
} }

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

@ -1,4 +1,5 @@
import { ImportModule } from '@ghostfolio/api/app/import/import.module'; import { ImportModule } from '@ghostfolio/api/app/import/import.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
@ -24,6 +25,7 @@ import { McpService } from './mcp.service';
ConfigurationModule, ConfigurationModule,
ImportModule, ImportModule,
PortfolioTableModule, PortfolioTableModule,
SymbolModule,
UserModule UserModule
], ],
providers: [ providers: [

35
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -1,6 +1,13 @@
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import {
MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH,
SEARCH_QUERY_MINIMUM_LENGTH
} from '@ghostfolio/common/config';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas'; import {
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas';
import { createActivity } from './mcp.test-utils'; import { createActivity } from './mcp.test-utils';
describe('IMPORT_ACTIVITIES_PARAMETERS', () => { describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
@ -46,3 +53,27 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => {
it(`Refuses a query shorter than ${SEARCH_QUERY_MINIMUM_LENGTH} characters`, () => {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: 'A' }).success
).toBe(false);
});
it(`Refuses a query longer than ${SEARCH_QUERY_MAXIMUM_LENGTH} characters`, () => {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({
query: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1)
}).success
).toBe(false);
});
it('Accepts a name, symbol or ISIN', () => {
for (const query of ['Apple', 'AAPL', 'US0378331005']) {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query }).success
).toBe(true);
}
});
});

12
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -2,7 +2,9 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { import {
DATE_RANGES, DATE_RANGES,
MCP_MAX_ACCOUNTS, MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH,
SEARCH_QUERY_MINIMUM_LENGTH
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
isValidCurrencyCode, isValidCurrencyCode,
@ -76,6 +78,14 @@ export const GET_ACTIVITIES_PARAMETERS = z.object({
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
}); });
export const SEARCH_ASSET_PROFILES_PARAMETERS = z.object({
query: z
.string()
.min(SEARCH_QUERY_MINIMUM_LENGTH)
.max(SEARCH_QUERY_MAXIMUM_LENGTH)
.describe('The name, symbol or ISIN of the asset profile to find')
});
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
activities: z activities: z
.array( .array(

87
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -1,5 +1,6 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -13,7 +14,12 @@ import { permissions } from '@ghostfolio/common/permissions';
import type { UserWithSettings } from '@ghostfolio/common/types'; import type { UserWithSettings } from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common'; import { HttpException } from '@nestjs/common';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { McpService } from './mcp.service'; import { McpService } from './mcp.service';
import { createActivity } from './mcp.test-utils'; import { createActivity } from './mcp.test-utils';
@ -29,12 +35,18 @@ describe('McpService', () => {
let importService: ImportService; let importService: ImportService;
let mcpService: McpService; let mcpService: McpService;
let portfolioTableService: PortfolioTableService; let portfolioTableService: PortfolioTableService;
let symbolService: SymbolService;
let userService: UserService; let userService: UserService;
function setupUser(userPermissions: string[]) { function setupUser(userPermissions: string[]) {
jest.spyOn(userService, 'user').mockResolvedValue({ const user = {
id: userId,
permissions: userPermissions permissions: userPermissions
} as UserWithSettings); } as UserWithSettings;
jest.spyOn(userService, 'user').mockResolvedValue(user);
return user;
} }
beforeEach(() => { beforeEach(() => {
@ -63,6 +75,10 @@ describe('McpService', () => {
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings') getHoldingsTable: jest.fn().mockResolvedValue('## Holdings')
} as unknown as PortfolioTableService; } as unknown as PortfolioTableService;
symbolService = {
lookup: jest.fn().mockResolvedValue({ items: [] })
} as unknown as SymbolService;
userService = { user: jest.fn() } as unknown as UserService; userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService( mcpService = new McpService(
@ -70,6 +86,7 @@ describe('McpService', () => {
configurationService, configurationService,
importService, importService,
portfolioTableService, portfolioTableService,
symbolService,
userService userService
); );
}); });
@ -182,6 +199,70 @@ describe('McpService', () => {
}); });
}); });
describe('searchAssetProfiles', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
mcpService.searchAssetProfiles({ query: 'Apple', userId })
).rejects.toThrow(HttpException);
expect(symbolService.lookup).not.toHaveBeenCalled();
});
it('Gives the import-ready asset profiles available to the user', async () => {
const user = setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest.spyOn(symbolService, 'lookup').mockResolvedValue({
items: [
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataProviderInfo: { isPremium: false },
dataSource: DataSource.YAHOO,
name: 'Apple Inc.',
symbol: 'AAPL'
},
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataProviderInfo: { isPremium: true },
dataSource: DataSource.GHOSTFOLIO,
name: 'Premium asset',
symbol: 'PREMIUM'
}
]
});
const result = await mcpService.searchAssetProfiles({
query: 'Apple',
userId
});
expect(symbolService.lookup).toHaveBeenCalledWith({
query: 'Apple',
user
});
expect(JSON.parse(result.content[0].text)).toEqual({
assetProfiles: [
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataSource: DataSource.GHOSTFOLIO,
name: 'Apple Inc.',
symbol: 'AAPL'
}
]
});
});
});
describe('importActivities', () => { describe('importActivities', () => {
it('Refuses a user without the permission to create an activity', async () => { it('Refuses a user without the permission to create an activity', async () => {
setupUser([]); setupUser([]);

60
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -1,6 +1,10 @@
import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import {
getMaskedGhostfolioDataSource,
getUnmaskedGhostfolioDataSource
} from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
@ -15,7 +19,8 @@ import { z } from 'zod';
import { import {
GET_ACCOUNTS_PARAMETERS, GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS, GET_ACTIVITIES_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas'; } from './mcp.schemas';
@Injectable() @Injectable()
@ -25,6 +30,7 @@ export class McpService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
private readonly portfolioTableService: PortfolioTableService, private readonly portfolioTableService: PortfolioTableService,
private readonly symbolService: SymbolService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -146,6 +152,56 @@ export class McpService {
return this.getTextResult(text); return this.getTextResult(text);
} }
public async searchAssetProfiles({
query,
userId
}: z.infer<typeof SEARCH_ASSET_PROFILES_PARAMETERS> & { userId: string }) {
const user = await this.getUserWithPermission({
userId,
permission: permissions.createActivity
});
const { items } = await this.symbolService.lookup({ query, user });
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const assetProfiles = items.flatMap(
({
assetClass,
assetSubClass,
currency,
dataProviderInfo,
dataSource,
name,
symbol
}) => {
if (!dataSource || dataProviderInfo.isPremium) {
return [];
}
return [
{
assetClass,
assetSubClass,
currency,
name,
symbol,
dataSource: getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
})
}
];
}
);
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2));
}
private getTextResult(text: string) { private getTextResult(text: string) {
return { content: [{ text, type: 'text' as const }] }; return { content: [{ text, type: 'text' as const }] };
} }

Loading…
Cancel
Save