Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/MCP/Base/CacheScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// Who may reuse a cached MCP result.
public enum CacheScope: String, Hashable, Codable, Sendable {
case `public`
case `private`
}
14 changes: 13 additions & 1 deletion Sources/MCP/Server/Prompts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,33 +288,45 @@ 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)
}

public init(from decoder: Decoder) throws {
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)
}
}
Expand Down
42 changes: 40 additions & 2 deletions Sources/MCP/Server/Resources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,33 +267,45 @@ 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)
}

public init(from decoder: Decoder) throws {
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)
}
}
Expand All @@ -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)
}
}
Expand All @@ -363,36 +387,50 @@ 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
}

public func encode(to encoder: Encoder) throws {
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)
}

public init(from decoder: Decoder) throws {
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)
}
}
Expand Down
14 changes: 13 additions & 1 deletion Sources/MCP/Server/Tools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,33 +325,45 @@ 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)
}

public init(from decoder: Decoder) throws {
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)
}
}
Expand Down
103 changes: 103 additions & 0 deletions Tests/MCPTests/CacheableResultTests.swift
Original file line number Diff line number Diff line change
@@ -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<M: MCP.Method>(
_ response: Response<M>, 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)
}
}