Class McpServerAudienceService

java.lang.Object
io.meeds.mcp.server.service.McpServerAudienceService

@Service public class McpServerAudienceService extends Object
Holds the audience of the MCP server: the list of permission expressions saying which users may open an MCP session at all, beside the global mcp.server on/off flag that McpServerToolService.isMcpServerEnabled() reads.

The storage idiom is the one AiSettingService established in the ai addon — a SettingService entry with a property default — but deliberately not its field memo. That service's own Javadoc records why memoising a defaulted read is a trap: a blank read is indistinguishable from an absent setting, so one failed read pins the default for the JVM's lifetime. Here the default is the widest audience there is, so that trap is a permanently open door. A field memo guarded against only that much still leaves two ways to serve a stale wide audience, and on a security input staleness is the whole risk:

  • a reader that has already read the store when a narrowing save lands publishes the pre-narrowing list after the save dropped the memo, and that value then answers every later call;
  • ListenerService is an in-JVM event bus, so a save on one cluster node never invalidates the memo held by the others, which keep answering the old audience with no TTL to age it out. Reading through SettingService is only better than that where the deployment configures its cache as a clustered or invalidating one — on a local-only cache configuration a per-call read is exactly as stale as a field.
So the audience is read on each call instead. That is not a database hit: SettingService is itself cache-backed, and it is the very cost ExoFeatureServiceImpl.isActiveFeature already pays per call for the global flag on this same path — with the cluster-awareness that a field above that cache would have removed.

The permission-expression grammar deliberately mirrors ExoFeatureServiceImpl's own exo.feature.<name>.permissions fallback. The audience is resolved by this service alone — no FeaturePlugin is registered for the feature, and nothing on the MCP request path asks ExoFeatureService.isFeatureActiveForUser, so that fallback never decides MCP access. Note which of those two facts does the work: the absence of a plugin is what activates the property fallback in ExoFeatureServiceImpl, so a caller reaching for that API would be answered "everybody" wherever the property is unset. The property is read here instead, as the default of defaultPermissions, so that a deployment which had narrowed MCP access with it keeps the exact access it had.

  • Field Details

    • DEFAULT_PERMISSION

      public static final String DEFAULT_PERMISSION
      Widest audience of internal users, and the shipped default: it is the value the platform's own exo.feature.<name>.permissions examples use, and it keeps MCP open to every member of /platform/users as the global flag alone did.

      It is not the exact status quo, and the difference is on purpose. An external user — a login that carries /platform/externals and not /platform/users, which PortalAuthenticationManager admits — could open an MCP session before this gate existed, because nothing asked a per-user question at all, and cannot after it. Narrowing MCP away from external accounts is the intended reading of "who may use the MCP server"; a deployment that wants them back adds *:/platform/externals to the audience. This belongs in the addon's upgrade notes, not only here.

      See Also:
    • PERMISSIONS_KEY

      public static final String PERMISSIONS_KEY
      See Also:
  • Constructor Details

    • McpServerAudienceService

      public McpServerAudienceService()
  • Method Details

    • isUserInAudience

      public boolean isUserInAudience(String username)
      Tells whether a user belongs to the MCP audience. This is the one place the question is answered: the gate, McpServerToolService.isMcpServerEnabledForUser, asks it directly, so no caller needs to know how the audience is stored.
      Parameters:
      username - the platform login of the end user, may be null or blank
      Returns:
      true when the user matches at least one permission expression of the audience, false when the user is unknown, when no username was resolved, or when an administrator deliberately emptied the audience
    • getPermissions

      public List<String> getPermissions()
      Returns:
      the audience as stored, or the property default when nothing is stored. Read from SettingService on every call and never held in a field — see this class' Javadoc for the two ways a memo here serves a stale, and therefore too wide, audience.
    • savePermissions

      public void savePermissions(List<String> audience)
      Replaces the audience. The change takes effect on the next request, on this node and on every other, because nothing above SettingService holds the previous value. The broadcast is an extension seam and nothing listens to it today: the ai administration UI re-reads the audience over REST after each save. An addon that ever keeps a per-user answer of its own would hook here.
      Parameters:
      audience - the new list of permission expressions; null or empty means nobody, as isUserInAudience(String) documents