Browse Source

Merge branch 'main' into bugfix/fallback-to-latest-market-price-in-current-rate-service

pull/7948/head
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
682f9fd7d2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      CHANGELOG.md
  2. 105
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  3. 8
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  4. 4
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  5. 11
      apps/api/src/decorators/requires-scope-of-access.decorator.ts
  6. 8
      apps/api/src/guards/access.guard.ts
  7. 31
      apps/api/src/helper/bearer-token.helper.spec.ts
  8. 29
      apps/api/src/helper/bearer-token.helper.ts
  9. 274
      libs/common/src/lib/personal-finance-tools.ts
  10. 84
      package-lock.json
  11. 4
      package.json

12
CHANGELOG.md

@ -10,15 +10,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the get quotes functionality of the _Manual_ service
### Fixed
- Fixed an issue where holdings without a quote have been valued at the unit price of the latest activity instead of the latest market price
## 3.73.0 - 2026-09-26
### Changed
- Improved the server of the Model Context Protocol (MCP) to list only the tools covered by the scopes of the access (experimental)
- Refreshed the cryptocurrencies list
- Improved the language localization for Catalan (`ca`)
- Upgraded `@rekog/mcp-nest` from version `2.0.2` to `2.0.7`
- Upgraded `bull-board` from version `9.9.0` to `9.10.1`
- Upgraded `zod` from version `4.5.4` to `4.6.5`
### Fixed
- Fixed the value of the holdings excluded from analysis in the portfolio summary
- Fixed an issue where holdings without a quote have been valued at the unit price of the latest activity instead of the latest market price
## 3.72.0 - 2026-09-20

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

@ -1,13 +1,26 @@
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { Scope, scopes } from '@ghostfolio/common/scopes';
import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import {
getScopesOfAccess,
getScopesOfAccessLevel,
Scope,
scopes
} from '@ghostfolio/common/scopes';
import {
EXCEPTION_FILTERS_METADATA,
GUARDS_METADATA
} from '@nestjs/common/constants';
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest';
import {
AccessMatchMode,
MCP_SCOPES_MATCH_METADATA_KEY,
MCP_SCOPES_METADATA_KEY,
MCP_TOOL_METADATA_KEY,
ToolAuthorizationService,
ToolMetadata
} from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller';
@ -35,6 +48,41 @@ function getToolMethodNames() {
);
}
/**
* Gives the names of the tools which the transport lists for the request. The
* metadata of each tool is read as the transport reads it.
*/
function getNamesOfListedTools(request: unknown) {
const toolAuthorizationService = new ToolAuthorizationService();
const user = getMcpUserOfBearerToken(request);
return getToolMethodNames()
.map((methodName) => {
return {
metadata: {
...getMetadataOfMethod<ToolMetadata>(
MCP_TOOL_METADATA_KEY,
methodName
),
requiredScopes: getMetadataOfMethod<string[]>(
MCP_SCOPES_METADATA_KEY,
methodName
),
requiredScopesMatch: getMetadataOfMethod<AccessMatchMode>(
MCP_SCOPES_MATCH_METADATA_KEY,
methodName
)
}
};
})
.filter((tool) => {
return toolAuthorizationService.canAccessTool(user, tool);
})
.map(({ metadata: { name } }) => {
return name;
});
}
describe('GhostfolioMcpController', () => {
// A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope
@ -71,6 +119,59 @@ describe('GhostfolioMcpController', () => {
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
});
it('Lists only the tools to read for an access with the permission "Restricted view"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('READ_RESTRICTED'),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-portfolio',
'get-watchlist'
]);
});
it('Lists every tool for an access with the permission "Restricted view and manage"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel(
'CREATE_READ_RESTRICTED_UPDATE_DELETE'
),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-portfolio',
'get-watchlist',
'import-activities',
'search-asset-profiles'
]);
});
it('Lists no tool for an inactive access', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: false,
scopes: getScopesOfAccessLevel('CREATE_READ_RESTRICTED_UPDATE_DELETE')
}
})
).toEqual([]);
});
it('Requires the scope to create an activity for the tool to import activities', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')

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

@ -101,12 +101,6 @@ export class GhostfolioMcpController {
return this.mcpService.getWatchlist({ userId });
}
/**
* The transport gives the tool to every client, because it filters the list
* of the tools by the scopes of request.user, which a request of an access
* never has. The guard refuses the call itself, hence the description names
* the permission which the access needs.
*/
@RequiresScopeOfAccess(scopes.activityCreate)
@Tool({
annotations: {
@ -115,7 +109,7 @@ export class GhostfolioMcpController {
readOnlyHint: false,
title: 'Import activities'
},
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.`,
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. 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',
parameters: IMPORT_ACTIVITIES_PARAMETERS
})

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

@ -2,6 +2,7 @@ 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 { environment } from '@ghostfolio/api/environments/environment';
import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -38,8 +39,9 @@ import { McpService } from './mcp.service';
return new McpStrategy({
instructions:
'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. 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 and the watchlist of the user who granted the access and import activities into the portfolio. They give no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage".',
name: 'ghostfolio',
resolveUser: getMcpUserOfBearerToken,
title: 'Ghostfolio',
transports: [
new StreamableHttpTransport({

11
apps/api/src/decorators/requires-scope-of-access.decorator.ts

@ -4,15 +4,24 @@ import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard';
import { Scope } from '@ghostfolio/common/scopes';
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { ToolScopes } from '@rekog/mcp-nest';
/**
* 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.
*
* The same scopes are declared to the transport of the model context
* protocol, which lists a tool only for an access whose scopes cover it. The
* transport refuses the call of a tool which it does not list with a protocol
* error before the guards and the filter of the exceptions run.
*/
export function RequiresScopeOfAccess(...requiredScopes: Scope[]) {
export function RequiresScopeOfAccess(scope: Scope, ...otherScopes: Scope[]) {
const requiredScopes = [scope, ...otherScopes];
return applyDecorators(
SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes),
ToolScopes(requiredScopes),
UseGuards(AccessGuard, ScopeGuard)
);
}

8
apps/api/src/guards/access.guard.ts

@ -1,3 +1,4 @@
import { getActiveImpersonationOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import { getRequest } from '@ghostfolio/api/helper/execution-context.helper';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -28,14 +29,17 @@ export class AccessGuard implements CanActivate {
public canActivate(context: ExecutionContext) {
const request = getRequest<RequestWithUser>(context);
if (!request?.impersonationOfBearerToken?.isActive) {
const impersonationOfBearerToken =
getActiveImpersonationOfBearerToken(request);
if (!impersonationOfBearerToken) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
request.impersonation = request.impersonationOfBearerToken;
request.impersonation = impersonationOfBearerToken;
return true;
}

31
apps/api/src/helper/bearer-token.helper.spec.ts

@ -0,0 +1,31 @@
import { scopes } from '@ghostfolio/common/scopes';
import { getMcpUserOfBearerToken } from './bearer-token.helper';
describe('getMcpUserOfBearerToken', () => {
it('should give the scopes of an active access', () => {
expect(
getMcpUserOfBearerToken({
impersonationOfBearerToken: {
isActive: true,
scopes: [scopes.portfolioRead]
}
})
).toEqual({ scopes: [scopes.portfolioRead] });
});
it('should give no user for an inactive access', () => {
expect(
getMcpUserOfBearerToken({
impersonationOfBearerToken: {
isActive: false,
scopes: [scopes.portfolioRead]
}
})
).toBeUndefined();
});
it('should give no user without an access', () => {
expect(getMcpUserOfBearerToken({})).toBeUndefined();
});
});

29
apps/api/src/helper/bearer-token.helper.ts

@ -1,3 +1,5 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
const PREFIX_OF_BEARER_TOKEN = 'bearer ';
/**
@ -16,3 +18,30 @@ export function getAccessIdOfBearerToken(authorization?: string) {
? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined
: undefined;
}
/**
* Gives the context of the access which the authorization middleware of the
* model context protocol resolved from the bearer token, but only while the
* access is active
*/
export function getActiveImpersonationOfBearerToken(
request?: Pick<RequestWithUser, 'impersonationOfBearerToken'>
) {
return request?.impersonationOfBearerToken?.isActive
? request.impersonationOfBearerToken
: undefined;
}
/**
* Gives the user which the transport of the model context protocol evaluates
* to list and to call the tools. It carries the scopes of the active access,
* hence the transport lists only the tools which the access covers.
*/
export function getMcpUserOfBearerToken(request: unknown) {
const impersonationOfBearerToken =
getActiveImpersonationOfBearerToken(request);
return impersonationOfBearerToken
? { scopes: impersonationOfBearerToken.scopes }
: undefined;
}

274
libs/common/src/lib/personal-finance-tools.ts

File diff suppressed because it is too large

84
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.72.0",
"version": "3.73.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.72.0",
"version": "3.73.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -49,7 +49,7 @@
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.2",
"@rekog/mcp-nest": "2.0.7",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",
@ -13614,14 +13614,13 @@
}
},
"node_modules/@rekog/mcp-nest": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.2.tgz",
"integrity": "sha512-kuye6wmRk5Gw4KZSCSXd83cWznRw4eFbvNUXsd8+1dYcS4uWKVVbRvxQIKNCyxc2UcljpsmLpnksr1NCRn3YAQ==",
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.7.tgz",
"integrity": "sha512-8DWrSapEA9Z/YJL9dFmj7sSbemmeQmf0VkGkW6Sn1itwKFf0n9hzTiwWt8XBtZvV5JVDNCtvRZKYYKgNjDcYPg==",
"license": "MIT",
"dependencies": {
"multer": "^2.2.0",
"path-to-regexp": "^8.4.2",
"rxjs": "^7.8.2"
"multer": "^2.3.0",
"path-to-regexp": "^8.4.2"
},
"peerDependencies": {
"@modelcontextprotocol/core": "^2.0.0-beta.5",
@ -13630,9 +13629,10 @@
"@nestjs/common": ">=9.0.0",
"@nestjs/core": ">=9.0.0",
"@nestjs/microservices": ">=9.0.0",
"@nestjs/platform-fastify": "^11.1.5",
"@nestjs/platform-fastify": "^11.1.5 || ^12.0.0",
"express": ">=4.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.1.0",
"zod": "^4.3.5"
},
"peerDependenciesMeta": {
@ -13641,13 +13641,65 @@
}
}
},
"node_modules/@rekog/mcp-nest/node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"node_modules/@rekog/mcp-nest/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rekog/mcp-nest/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rekog/mcp-nest/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rekog/mcp-nest/node_modules/multer": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@rekog/mcp-nest/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rolldown/binding-android-arm64": {

4
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.72.0",
"version": "3.73.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -93,7 +93,7 @@
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.2",
"@rekog/mcp-nest": "2.0.7",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",

Loading…
Cancel
Save