001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.mcp;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.util.Optional;
023import java.util.concurrent.*;
024
025/**
026 * Default {@link McpSubscriptionBroker}: a {@link ConcurrentHashMap}-backed registry doing filtered fan-out.
027 *
028 * <p>
029 * Publish methods ({@link #resourceUpdated}, {@link #toolsListChanged}, {@link #promptsListChanged},
030 * {@link #resourcesListChanged}) build one {@link McpChangeEvent} and iterate the live registry, offering the
031 * event only to subscriptions whose {@link McpSubscriptionFilter#matches(McpChangeEvent)} returns
032 * {@code true} — this never broadcasts unconditionally the way
033 * {@code org.apache.juneau.rest.server.sse.SseBroadcaster#publish} does.
034 */
035@SuppressWarnings({
036   "resource" // Registered subscriptions are closed by unregister() and when replaced; the caller owns the returned subscription
037})
038public class BasicMcpSubscriptionBroker implements McpSubscriptionBroker {
039
040   private final int queueSize;
041   private final ConcurrentMap<String,BasicMcpSubscription> subscriptions = new ConcurrentHashMap<>();
042   // Guards the check-then-mutate window in registerIfUnder only; plain register() is intentionally
043   // uncapped and does not synchronize on this (see the interface javadoc for the split rationale).
044   private final Object admissionLock = new Object();
045
046   /**
047    * Creates a new broker.
048    *
049    * @param queueSize The per-subscription drop-oldest queue bound. Must be {@code > 0}.
050    * @throws IllegalArgumentException If {@code queueSize} is not {@code > 0}.
051    */
052   public BasicMcpSubscriptionBroker(int queueSize) {
053      if (queueSize <= 0)
054         throw iaex("queueSize %s must be > 0", queueSize);
055      this.queueSize = queueSize;
056   }
057
058   @Override
059   public McpSubscription register(String subscriptionId, McpSubscriptionFilter honoredFilter) {
060      if (isEmpty(subscriptionId))
061         throw iaex("subscriptionId must not be null or empty");
062      if (honoredFilter == null)
063         throw iaex("honoredFilter must not be null");
064      return doRegister(subscriptionId, honoredFilter);
065   }
066
067   @Override
068   public Optional<McpSubscription> registerIfUnder(int max, String subscriptionId, McpSubscriptionFilter honoredFilter) {
069      if (isEmpty(subscriptionId))
070         throw iaex("subscriptionId must not be null or empty");
071      if (honoredFilter == null)
072         throw iaex("honoredFilter must not be null");
073      // The size check and the put must be indivisible from the perspective of concurrent registerIfUnder
074      // callers, or two racers can each observe activeCount() < max and both register, exceeding the cap.
075      synchronized (admissionLock) {
076         if (! subscriptions.containsKey(subscriptionId) && subscriptions.size() >= max)
077            return Optional.empty();
078         var sub = doRegister(subscriptionId, honoredFilter);
079         return Optional.of(sub);
080      }
081   }
082
083   private McpSubscription doRegister(String subscriptionId, McpSubscriptionFilter honoredFilter) {
084      // The close callback removes this exact instance only (via the two-arg conditional ConcurrentMap.remove),
085      // so closing a stale/replaced subscription can never evict the fresher one that has since replaced it.
086      var sub = new BasicMcpSubscription(subscriptionId, honoredFilter, queueSize,
087         closed -> subscriptions.remove(subscriptionId, closed));
088      var previous = subscriptions.put(subscriptionId, sub);
089      if (previous != null)
090         previous.close();
091      return sub;
092   }
093
094   @Override
095   public void unregister(String subscriptionId) {
096      if (subscriptionId == null)
097         throw iaex("subscriptionId must not be null");
098      var sub = subscriptions.remove(subscriptionId);
099      if (sub != null)
100         sub.close();
101   }
102
103   @Override
104   public int activeCount() {
105      return subscriptions.size();
106   }
107
108   @Override
109   public void closeAll() {
110      // close() removes the entry via the close callback; iterating a snapshot of the weakly-consistent view is safe.
111      for (var sub : subscriptions.values())
112         sub.close();
113   }
114
115   @Override
116   public void resourceUpdated(String uri) {
117      if (uri == null)
118         throw iaex("uri must not be null");
119      publish(new McpChangeEvent(McpChangeKind.RESOURCE_UPDATED, uri));
120   }
121
122   @Override
123   public void toolsListChanged() {
124      publish(new McpChangeEvent(McpChangeKind.TOOLS_LIST_CHANGED, null));
125   }
126
127   @Override
128   public void promptsListChanged() {
129      publish(new McpChangeEvent(McpChangeKind.PROMPTS_LIST_CHANGED, null));
130   }
131
132   @Override
133   public void resourcesListChanged() {
134      publish(new McpChangeEvent(McpChangeKind.RESOURCES_LIST_CHANGED, null));
135   }
136
137   private void publish(McpChangeEvent event) {
138      for (var sub : subscriptions.values())
139         if (sub.getFilter().matches(event))
140            sub.offer(event);
141   }
142}