blob: 15fc27e2717c039b634755feeb83cb831cfceb05 [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
part of 'client.dart';
final class _CacheKey {
const _CacheKey(this.methodName, this.parameter, this.context);
final String methodName;
final Object? parameter;
final Map<String, Object?>? context;
@override
bool operator ==(Object other) =>
other is _CacheKey &&
other.methodName == methodName &&
other.parameter == parameter &&
const DeepCollectionEquality().equals(other.context, context);
@override
int get hashCode => Object.hash(
methodName,
parameter,
const DeepCollectionEquality().hash(context),
);
}
final class _CacheEntry {
const _CacheEntry(this.result, this.expiresAt);
final Map<String, Object?> result;
/// Elapsed stopwatch time when this entry expires.
///
/// A [Duration] keeps expiry monotonic and is compared with the cache
/// stopwatch, not the wall clock.
final Duration expiresAt;
}
/// The `_meta` fields that change the answer to a cacheable request.
///
/// A request carrying any other field is sent to the server, since this
/// package cannot tell whether that field changes the answer.
const _contextKeys = {
Keys.protocolVersionMeta,
Keys.clientCapabilitiesMeta,
Keys.clientInfoMeta,
Keys.logLevelMeta,
};
/// The cache behind [ServerConnection.sendRequest].
///
/// Holds the results of the six operations the 2026-07-28 caching rules name,
/// keyed by the request method, the parameters that change the answer, and
/// the per-request context the caller sent. A result is stored only while it
/// is complete, carries a positive `ttlMs` and carries a scope the schema
/// allows, and the answers to an [InputRequiredResult] stay out entirely. A
/// `ttlMs` past the configured maximum is clamped to that limit, since without
/// a bound a server could pin an answer for the life of the process.
///
/// https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching
final class _ClientResponseCache {
int maxEntries = 512;
Duration maxTtl = const Duration(hours: 24);
final _entries = <_CacheKey, _CacheEntry>{};
final _pending = <_CacheKey, Future<Result?>>{};
/// The resource URIs updated while a `resources/read` was in flight, under
/// the token of that read.
final _updatedWhilePending = <Future<Result?>, Set<String>>{};
final _clock = Stopwatch()..start();
Duration _elapsedOffset = Duration.zero;
/// Cache time is the live stopwatch reading plus this test-only offset.
Duration get _elapsed => _clock.elapsed + _elapsedOffset;
Future<T> sendRequest<T extends Result?>(
String methodName,
Request? request,
Future<T> Function() send,
) async {
final requestMap = request as Map<String, Object?>?;
final meta = requestMap?[Keys.meta];
final metaMap = meta is Map<String, Object?> ? meta : null;
final cacheRequest =
requestMap == null || metaMap == null
? requestMap
: ({...requestMap}..remove(Keys.meta));
final key =
metaMap != null && !metaMap.keys.every(_contextKeys.contains)
? null
: _keyFor(methodName, cacheRequest, _contextOf(metaMap));
try {
return await (key == null ? send() : _sendAndStore<T>(key, send));
} on RpcException catch (error) {
if (error.code == error_code.INVALID_PARAMS &&
cacheRequest?.containsKey(Keys.cursor) == true) {
invalidateMethod(methodName);
}
rethrow;
}
}
Future<T> _sendAndStore<T extends Result?>(
_CacheKey key,
Future<T> Function() send,
) async {
final cached = _cachedEntry(key);
if (cached != null) return _copyMap(cached.result) as T;
final pending = _pending[key];
if (pending != null) {
try {
await pending;
} catch (_) {
// The caller that started the request receives its error. This caller
// still gets a chance to make a fresh request.
}
final cached = _cachedEntry(key);
if (cached != null) return _copyMap(cached.result) as T;
}
late final Future<T> current;
current = _requestAndStore<T>(key, send, () => current);
_pending[key] = current;
try {
return await current;
} finally {
if (identical(_pending[key], current)) unawaited(_pending.remove(key));
_updatedWhilePending.remove(current);
}
}
_CacheEntry? _cachedEntry(_CacheKey key) {
final entry = _entries[key];
if (entry == null) return null;
if (entry.expiresAt.compareTo(_elapsed) > 0) return entry;
_entries.remove(key);
return null;
}
Future<T> _requestAndStore<T extends Result?>(
_CacheKey key,
Future<T> Function() send,
Future<T> Function() current,
) async {
final result = await send();
final receivedAt = _elapsed;
final pending = current();
if (result == null || !identical(_pending[key], pending)) return result;
final resultMap = result as Map<String, Object?>;
final resultType = resultMap[Keys.resultType];
final ttlMs = resultMap[Keys.ttlMs];
final cacheScope = resultMap[Keys.cacheScope];
// A server on an earlier revision leaves `resultType` out, and the
// schema requires reading that as complete. `Result.resultType` reports
// the same value. An [InputRequiredResult] carries the
// fields the client retries with, so a result carrying them is interim
// whatever it labels itself. A hint outside what the schema allows is
// read as no hint at all, matching `CacheableResult`.
if ((resultType != null && resultType != ResultTypes.complete) ||
resultMap.containsKey(Keys.inputRequests) ||
resultMap.containsKey(Keys.requestState) ||
ttlMs is! int ||
ttlMs <= 0 ||
!CacheScope.values.any((scope) => scope.name == cacheScope)) {
return result;
}
final ttl =
ttlMs > maxTtl.inMilliseconds ? maxTtl : Duration(milliseconds: ttlMs);
if (ttl <= Duration.zero || maxEntries == 0) return result;
if (!_entries.containsKey(key) && _entries.length >= maxEntries) {
_entries.remove(_entries.keys.first);
}
// An answer that arrives after one of its own contents changed is already
// out of date, and a read only learns its contents once it gets here.
final updated = _updatedWhilePending[pending];
if (updated != null && _carriesAny(resultMap, updated)) return result;
// The TTL is added to the stopwatch reading captured when the response
// arrived. Saturation keeps the monotonic deadline in range.
final expiresAt = receivedAt + ttl;
_entries[key] = _CacheEntry(_copyMap(resultMap), expiresAt);
return result;
}
/// The values from [meta] which change the answer to a cacheable request.
Map<String, Object?>? _contextOf(Map<String, Object?>? meta) {
if (meta == null) return null;
final context = <String, Object?>{
for (final key in _contextKeys)
if (meta.containsKey(key)) key: _copyValue(meta[key]),
};
return context.isEmpty ? null : context;
}
void invalidateMethod(String methodName) {
_entries.removeWhere((key, _) => key.methodName == methodName);
_pending.removeWhere((key, _) => key.methodName == methodName);
}
void invalidateResource(String uri) {
// The notification can name a sub-resource of the one that was read, so a
// result carrying that URI in its contents is stale as well.
bool matches(_CacheKey key, _CacheEntry? entry) {
if (key.methodName != ReadResourceRequest.methodName) return false;
if (key.parameter == uri) return true;
final contents = entry?.result[Keys.contents];
return contents is List &&
contents.any((content) => content is Map && content[Keys.uri] == uri);
}
_entries.removeWhere(matches);
_pending.removeWhere((key, _) => matches(key, null));
for (final MapEntry(key: pending, :value) in _pending.entries) {
if (pending.methodName == ReadResourceRequest.methodName) {
(_updatedWhilePending[value] ??= <String>{}).add(uri);
}
}
}
/// Whether [result] carries content for any URI in [uris].
static bool _carriesAny(Map<String, Object?> result, Set<String> uris) {
if (uris.isEmpty) return false;
final contents = result[Keys.contents];
return contents is List &&
contents.any(
(content) => content is Map && uris.contains(content[Keys.uri]),
);
}
void clear() {
_entries.clear();
_pending.clear();
_updatedWhilePending.clear();
}
/// The key [request] is cached under, or `null` when it is not cacheable.
///
/// Each cacheable method names the parameters that change its answer, and
/// a request carrying anything else gets no key. That is what keeps the
/// retry of an [InputRequiredResult] out: the schema says a request carrying
/// `inputResponses` or `requestState` must not be cached, since it depends
/// on inputs no key covers, and such a request never matches one of these
/// shapes.
_CacheKey? _keyFor(
String methodName,
Map<String, Object?>? request,
Map<String, Object?>? context,
) => switch (methodName) {
DiscoverRequest.methodName =>
request == null || request.isEmpty
? _CacheKey(methodName, null, context)
: null,
ListPromptsRequest.methodName ||
ListResourcesRequest.methodName ||
ListResourceTemplatesRequest.methodName ||
ListToolsRequest.methodName => _listKey(methodName, request, context),
ReadResourceRequest.methodName => _readResourceKey(
methodName,
request,
context,
),
_ => null,
};
_CacheKey? _listKey(
String methodName,
Map<String, Object?>? request,
Map<String, Object?>? context,
) {
if (request == null || request.isEmpty) {
return _CacheKey(methodName, null, context);
}
final cursor = request[Keys.cursor];
if (request.length != 1 || cursor is! String) return null;
return _CacheKey(methodName, cursor, context);
}
_CacheKey? _readResourceKey(
String methodName,
Map<String, Object?>? request,
Map<String, Object?>? context,
) {
if (request == null) return null;
final uri = request[Keys.uri];
if (request.length != 1 || uri is! String) return null;
return _CacheKey(methodName, uri, context);
}
}
Map<String, Object?> _copyMap(Map<String, Object?> value) => {
for (final entry in value.entries) entry.key: _copyValue(entry.value),
};
Object? _copyValue(Object? value) {
if (value is Map<String, Object?>) return _copyMap(value);
if (value is Map<Object?, Object?>) {
return <Object?, Object?>{
for (final entry in value.entries) entry.key: _copyValue(entry.value),
};
}
if (value is List<Object?>) {
return <Object?>[for (final item in value) _copyValue(item)];
}
return value;
}