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}