From 8210172a8c00d81206770cc78f4e7a34f9f34ea6 Mon Sep 17 00:00:00 2001 From: Ehsan Azish Date: Thu, 24 Sep 2026 19:09:17 +0300 Subject: [PATCH] Add cache fields to list and resource results --- Sources/MCP/Base/CacheScope.swift | 5 ++ Sources/MCP/Server/Prompts.swift | 14 ++- Sources/MCP/Server/Resources.swift | 42 ++++++++- Sources/MCP/Server/Tools.swift | 14 ++- Tests/MCPTests/CacheableResultTests.swift | 103 ++++++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 Sources/MCP/Base/CacheScope.swift create mode 100644 Tests/MCPTests/CacheableResultTests.swift diff --git a/Sources/MCP/Base/CacheScope.swift b/Sources/MCP/Base/CacheScope.swift new file mode 100644 index 00000000..ced441e4 --- /dev/null +++ b/Sources/MCP/Base/CacheScope.swift @@ -0,0 +1,5 @@ +/// Who may reuse a cached MCP result. +public enum CacheScope: String, Hashable, Codable, Sendable { + case `public` + case `private` +} diff --git a/Sources/MCP/Server/Prompts.swift b/Sources/MCP/Server/Prompts.swift index acfdcad8..2720f768 100644 --- a/Sources/MCP/Server/Prompts.swift +++ b/Sources/MCP/Server/Prompts.swift @@ -288,26 +288,36 @@ public enum ListPrompts: Method { public struct Result: Hashable, Codable, Sendable { public let prompts: [Prompt] public let nextCursor: String? + /// Cache lifetime in milliseconds. Zero means immediately stale. + public let ttlMs: Int? + /// Whether this result may be reused across authorization contexts. + public let cacheScope: CacheScope? public var _meta: Metadata? public init( prompts: [Prompt], nextCursor: String? = nil, + ttlMs: Int? = nil, + cacheScope: CacheScope? = nil, _meta: Metadata? = nil ) { self.prompts = prompts self.nextCursor = nextCursor + self.ttlMs = ttlMs.map { max(0, $0) } + self.cacheScope = cacheScope self._meta = _meta } private enum CodingKeys: String, CodingKey, CaseIterable { - case prompts, nextCursor, _meta + case prompts, nextCursor, ttlMs, cacheScope, _meta } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(prompts, forKey: .prompts) try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + try container.encodeIfPresent(ttlMs, forKey: .ttlMs) + try container.encodeIfPresent(cacheScope, forKey: .cacheScope) try container.encodeIfPresent(_meta, forKey: ._meta) } @@ -315,6 +325,8 @@ public enum ListPrompts: Method { let container = try decoder.container(keyedBy: CodingKeys.self) prompts = try container.decode([Prompt].self, forKey: .prompts) nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + ttlMs = try container.decodeIfPresent(Int.self, forKey: .ttlMs).map { max(0, $0) } + cacheScope = try container.decodeIfPresent(CacheScope.self, forKey: .cacheScope) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) } } diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index fb512a03..f55a1fb3 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -267,26 +267,36 @@ public enum ListResources: Method { public struct Result: Hashable, Codable, Sendable { public let resources: [Resource] public let nextCursor: String? + /// Cache lifetime in milliseconds. Zero means immediately stale. + public let ttlMs: Int? + /// Whether this result may be reused across authorization contexts. + public let cacheScope: CacheScope? public var _meta: Metadata? public init( resources: [Resource], nextCursor: String? = nil, + ttlMs: Int? = nil, + cacheScope: CacheScope? = nil, _meta: Metadata? = nil ) { self.resources = resources self.nextCursor = nextCursor + self.ttlMs = ttlMs.map { max(0, $0) } + self.cacheScope = cacheScope self._meta = _meta } private enum CodingKeys: String, CodingKey, CaseIterable { - case resources, nextCursor, _meta + case resources, nextCursor, ttlMs, cacheScope, _meta } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(resources, forKey: .resources) try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + try container.encodeIfPresent(ttlMs, forKey: .ttlMs) + try container.encodeIfPresent(cacheScope, forKey: .cacheScope) try container.encodeIfPresent(_meta, forKey: ._meta) } @@ -294,6 +304,8 @@ public enum ListResources: Method { let container = try decoder.container(keyedBy: CodingKeys.self) resources = try container.decode([Resource].self, forKey: .resources) nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + ttlMs = try container.decodeIfPresent(Int.self, forKey: .ttlMs).map { max(0, $0) } + cacheScope = try container.decodeIfPresent(CacheScope.self, forKey: .cacheScope) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) } } @@ -314,30 +326,42 @@ public enum ReadResource: Method { public struct Result: Hashable, Codable, Sendable { public let contents: [Resource.Content] + /// Cache lifetime in milliseconds. Zero means immediately stale. + public let ttlMs: Int? + /// Whether this result may be reused across authorization contexts. + public let cacheScope: CacheScope? /// Optional metadata about this result public var _meta: Metadata? public init( contents: [Resource.Content], + ttlMs: Int? = nil, + cacheScope: CacheScope? = nil, _meta: Metadata? = nil ) { self.contents = contents + self.ttlMs = ttlMs.map { max(0, $0) } + self.cacheScope = cacheScope self._meta = _meta } private enum CodingKeys: String, CodingKey, CaseIterable { - case contents, _meta + case contents, ttlMs, cacheScope, _meta } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(contents, forKey: .contents) + try container.encodeIfPresent(ttlMs, forKey: .ttlMs) + try container.encodeIfPresent(cacheScope, forKey: .cacheScope) try container.encodeIfPresent(_meta, forKey: ._meta) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) contents = try container.decode([Resource.Content].self, forKey: .contents) + ttlMs = try container.decodeIfPresent(Int.self, forKey: .ttlMs).map { max(0, $0) } + cacheScope = try container.decodeIfPresent(CacheScope.self, forKey: .cacheScope) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) } } @@ -363,22 +387,32 @@ public enum ListResourceTemplates: Method { public struct Result: Hashable, Codable, Sendable { public let templates: [Resource.Template] public let nextCursor: String? + /// Cache lifetime in milliseconds. Zero means immediately stale. + public let ttlMs: Int? + /// Whether this result may be reused across authorization contexts. + public let cacheScope: CacheScope? /// Optional metadata about this result public var _meta: Metadata? public init( templates: [Resource.Template], nextCursor: String? = nil, + ttlMs: Int? = nil, + cacheScope: CacheScope? = nil, _meta: Metadata? = nil ) { self.templates = templates self.nextCursor = nextCursor + self.ttlMs = ttlMs.map { max(0, $0) } + self.cacheScope = cacheScope self._meta = _meta } private enum CodingKeys: String, CodingKey, CaseIterable { case templates = "resourceTemplates" case nextCursor + case ttlMs + case cacheScope case _meta } @@ -386,6 +420,8 @@ public enum ListResourceTemplates: Method { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(templates, forKey: .templates) try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + try container.encodeIfPresent(ttlMs, forKey: .ttlMs) + try container.encodeIfPresent(cacheScope, forKey: .cacheScope) try container.encodeIfPresent(_meta, forKey: ._meta) } @@ -393,6 +429,8 @@ public enum ListResourceTemplates: Method { let container = try decoder.container(keyedBy: CodingKeys.self) templates = try container.decode([Resource.Template].self, forKey: .templates) nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + ttlMs = try container.decodeIfPresent(Int.self, forKey: .ttlMs).map { max(0, $0) } + cacheScope = try container.decodeIfPresent(CacheScope.self, forKey: .cacheScope) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) } } diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 478ba601..258a2f2b 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -325,26 +325,36 @@ public enum ListTools: Method { public struct Result: Hashable, Codable, Sendable { public let tools: [Tool] public let nextCursor: String? + /// Cache lifetime in milliseconds. Zero means immediately stale. + public let ttlMs: Int? + /// Whether this result may be reused across authorization contexts. + public let cacheScope: CacheScope? public var _meta: Metadata? public init( tools: [Tool], nextCursor: String? = nil, + ttlMs: Int? = nil, + cacheScope: CacheScope? = nil, _meta: Metadata? = nil ) { self.tools = tools self.nextCursor = nextCursor + self.ttlMs = ttlMs.map { max(0, $0) } + self.cacheScope = cacheScope self._meta = _meta } private enum CodingKeys: String, CodingKey, CaseIterable { - case tools, nextCursor, _meta + case tools, nextCursor, ttlMs, cacheScope, _meta } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(tools, forKey: .tools) try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + try container.encodeIfPresent(ttlMs, forKey: .ttlMs) + try container.encodeIfPresent(cacheScope, forKey: .cacheScope) try container.encodeIfPresent(_meta, forKey: ._meta) } @@ -352,6 +362,8 @@ public enum ListTools: Method { let container = try decoder.container(keyedBy: CodingKeys.self) tools = try container.decode([Tool].self, forKey: .tools) nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + ttlMs = try container.decodeIfPresent(Int.self, forKey: .ttlMs).map { max(0, $0) } + cacheScope = try container.decodeIfPresent(CacheScope.self, forKey: .cacheScope) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) } } diff --git a/Tests/MCPTests/CacheableResultTests.swift b/Tests/MCPTests/CacheableResultTests.swift new file mode 100644 index 00000000..c5d0e989 --- /dev/null +++ b/Tests/MCPTests/CacheableResultTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Cacheable result tests") +struct CacheableResultTests { + @Test("July result fields decode for each cacheable method") + func decodesCacheFields() throws { + let decoder = JSONDecoder() + + let tools = try decoder.decode(ListTools.Result.self, from: Data(#"{"tools":[],"ttlMs":300000,"cacheScope":"public"}"#.utf8)) + #expect(tools.ttlMs == 300000) + #expect(tools.cacheScope == .public) + + let prompts = try decoder.decode(ListPrompts.Result.self, from: Data(#"{"prompts":[],"ttlMs":0,"cacheScope":"private"}"#.utf8)) + #expect(prompts.ttlMs == 0) + #expect(prompts.cacheScope == .private) + + let resources = try decoder.decode(ListResources.Result.self, from: Data(#"{"resources":[],"ttlMs":1000,"cacheScope":"private"}"#.utf8)) + #expect(resources.ttlMs == 1000) + #expect(resources.cacheScope == .private) + + let read = try decoder.decode(ReadResource.Result.self, from: Data(#"{"contents":[],"ttlMs":500,"cacheScope":"private"}"#.utf8)) + #expect(read.ttlMs == 500) + #expect(read.cacheScope == .private) + + let templates = try decoder.decode(ListResourceTemplates.Result.self, from: Data(#"{"resourceTemplates":[],"ttlMs":60000,"cacheScope":"public"}"#.utf8)) + #expect(templates.ttlMs == 60000) + #expect(templates.cacheScope == .public) + } + + @Test("Server results emit cache fields in JSON-RPC responses") + func encodesCacheFields() throws { + try expectCacheFields( + ListTools.response(id: "tools", result: .init(tools: [], ttlMs: 1000, cacheScope: .public)), + resultKey: "tools", scope: "public") + try expectCacheFields( + ListPrompts.response(id: "prompts", result: .init(prompts: [], ttlMs: 1000, cacheScope: .private)), + resultKey: "prompts", scope: "private") + try expectCacheFields( + ListResources.response(id: "resources", result: .init(resources: [], ttlMs: 1000, cacheScope: .private)), + resultKey: "resources", scope: "private") + try expectCacheFields( + ReadResource.response(id: "read", result: .init(contents: [], ttlMs: 1000, cacheScope: .private)), + resultKey: "contents", scope: "private") + try expectCacheFields( + ListResourceTemplates.response(id: "templates", result: .init(templates: [], ttlMs: 1000, cacheScope: .public)), + resultKey: "resourceTemplates", scope: "public") + } + + @Test("Older results without cache fields still decode and do not gain a cache policy") + func decodesLegacyResults() throws { + let decoder = JSONDecoder() + let tools = try decoder.decode(ListTools.Result.self, from: Data(#"{"tools":[]}"#.utf8)) + let prompts = try decoder.decode(ListPrompts.Result.self, from: Data(#"{"prompts":[]}"#.utf8)) + let resources = try decoder.decode(ListResources.Result.self, from: Data(#"{"resources":[]}"#.utf8)) + let read = try decoder.decode(ReadResource.Result.self, from: Data(#"{"contents":[]}"#.utf8)) + let templates = try decoder.decode(ListResourceTemplates.Result.self, from: Data(#"{"resourceTemplates":[]}"#.utf8)) + + #expect(tools.ttlMs == nil && tools.cacheScope == nil) + #expect(prompts.ttlMs == nil && prompts.cacheScope == nil) + #expect(resources.ttlMs == nil && resources.cacheScope == nil) + #expect(read.ttlMs == nil && read.cacheScope == nil) + #expect(templates.ttlMs == nil && templates.cacheScope == nil) + + let legacyJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(tools)) as? [String: Any] + #expect(legacyJSON?["ttlMs"] == nil) + #expect(legacyJSON?["cacheScope"] == nil) + } + + @Test("Unknown cache scope is rejected") + func rejectsUnknownScope() { + let data = Data(#"{"tools":[],"ttlMs":1000,"cacheScope":"shared"}"#.utf8) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ListTools.Result.self, from: data) + } + } + + @Test("Negative TTL is immediately stale and never emitted") + func normalizesNegativeTTL() throws { + let data = Data(#"{"tools":[],"ttlMs":-1,"cacheScope":"private"}"#.utf8) + let decoded = try JSONDecoder().decode(ListTools.Result.self, from: data) + #expect(decoded.ttlMs == 0) + + let result = ListTools.Result(tools: [], ttlMs: -100, cacheScope: .private) + #expect(result.ttlMs == 0) + let json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(result)) as? [String: Any]) + #expect(json["ttlMs"] as? Int == 0) + } + + private func expectCacheFields( + _ response: Response, resultKey: String, scope: String + ) throws { + let data = try JSONEncoder().encode(response) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let result = try #require(json["result"] as? [String: Any]) + #expect(json["jsonrpc"] as? String == "2.0") + #expect(result[resultKey] as? [Any] != nil) + #expect(result["ttlMs"] as? Int == 1000) + #expect(result["cacheScope"] as? String == scope) + } +}