feat: 2025-11-25 review fixes (#201)

* fix: review fixes of the latest changes

* fix: updated unit tests

* chore: more conformance compliance improvements

* chore: reverted protocol changes

* chore: more compliance improvements

* chore: more conformance compliance improvements

* chore: improvements to the backcompat and updated to Tool unit tests

* chore: ci update
This commit is contained in:
yehorsobko 2026-03-03 16:33:28 +01:00 • committed by GitHub
commit 973b46b8f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 692 additions and 507 deletions

View file

@ -38,11 +38,27 @@ public actor Client {
public var title: String?
/// The client version
public var version: String
/// Optional description of the client
public var description: String?
/// Optional website URL for the client
public var websiteUrl: String?
/// Optional set of sized icons for display in a user interface
public var icons: [Icon]?
public init(name: String, version: String, title: String? = nil) {
public init(
name: String,
version: String,
title: String? = nil,
description: String? = nil,
websiteUrl: String? = nil,
icons: [Icon]? = nil
) {
self.name = name
self.title = title
self.version = version
self.description = description
self.websiteUrl = websiteUrl
self.icons = icons
}
}
@ -163,46 +179,6 @@ public actor Client {
/// The task for the message handling loop
private var task: Task<Void, Never>?
/// An error indicating a type mismatch when decoding a pending request
private struct TypeMismatchError: Swift.Error {}
/// A pending request with a continuation for the result
private struct PendingRequest<T> {
let continuation: CheckedContinuation<T, Swift.Error>
}
/// A type-erased pending request
private struct AnyPendingRequest: Sendable {
private let _resume: @Sendable (Result<Any, Swift.Error>) -> Void
init<T: Sendable & Decodable>(_ request: PendingRequest<T>) {
_resume = { result in
switch result {
case .success(let value):
if let typedValue = value as? T {
request.continuation.resume(returning: typedValue)
} else if let value = value as? Value,
let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode(T.self, from: data)
{
request.continuation.resume(returning: decoded)
} else {
request.continuation.resume(throwing: TypeMismatchError())
}
case .failure(let error):
request.continuation.resume(throwing: error)
}
}
}
func resume(returning value: Any) {
_resume(.success(value))
}
func resume(throwing error: Swift.Error) {
_resume(.failure(error))
}
}
/// A dictionary of type-erased pending requests, keyed by request ID
private var pendingRequests: [ID: AnyPendingRequest] = [:]
// Add reusable JSON encoder/decoder
@ -213,10 +189,15 @@ public actor Client {
name: String,
version: String,
title: String? = nil,
description: String? = nil,
websiteUrl: String? = nil,
icons: [Icon]? = nil,
capabilities: Capabilities = Capabilities(),
configuration: Configuration = .default
) {
self.clientInfo = Client.Info(name: name, version: version, title: title)
self.clientInfo = Client.Info(
name: name, version: version, title: title,
description: description, websiteUrl: websiteUrl, icons: icons)
self.capabilities = capabilities
self.configuration = configuration
}
@ -285,13 +266,15 @@ public actor Client {
await self.logger?.debug(
"Received cancellation notification",
metadata: [
"requestId": "\(requestId)",
"requestId": requestId.map { "\($0)" } ?? "none",
"reason": reason.map { "\($0)" } ?? "none",
]
)
// Remove the pending request and resume with cancellation error
if let pendingRequest = await self.removePendingRequest(id: requestId) {
if let requestId = requestId,
let pendingRequest = await self.removePendingRequest(id: requestId)
{
pendingRequest.resume(throwing: CancellationError())
}
}
@ -671,6 +654,12 @@ public actor Client {
self.serverVersion = result.protocolVersion
self.instructions = result.instructions
// For HTTP transport, ensure subsequent MCP-Protocol-Version headers
// reflect the negotiated lifecycle version.
if let httpTransport = connection as? HTTPClientTransport {
await httpTransport.updateNegotiatedProtocolVersion(result.protocolVersion)
}
try await notify(InitializedNotification.message())
return result
@ -683,7 +672,7 @@ public actor Client {
// MARK: - Prompts
public func getPrompt(name: String, arguments: [String: Value]? = nil) async throws
public func getPrompt(name: String, arguments: [String: String]? = nil) async throws
-> (description: String?, messages: [Prompt.Message])
{
try validateServerCapability(\.prompts, "Prompts")
@ -923,7 +912,7 @@ public actor Client {
promptName: String,
argumentName: String,
argumentValue: String,
context: [String: Value]? = nil
context: [String: String]? = nil
) async throws -> Complete.Result.Completion {
try validateServerCapability(\.completions, "Completions")
let request = Complete.request(
@ -954,7 +943,7 @@ public actor Client {
resourceURI: String,
argumentName: String,
argumentValue: String,
context: [String: Value]? = nil
context: [String: String]? = nil
) async throws -> Complete.Result.Completion {
try validateServerCapability(\.completions, "Completions")
let request = Complete.request(

View file

@ -68,15 +68,15 @@ public enum CreateElicitation: Method {
public var message: String
/// Elicitation mode (optional for backward compatibility, defaults to form)
public var mode: Elicitation.Mode?
/// Optional schema describing the expected response content
public var requestedSchema: Elicitation.RequestSchema?
/// Schema describing the expected response content (required per spec)
public var requestedSchema: Elicitation.RequestSchema
/// Optional metadata
public var _meta: Metadata?
public init(
message: String,
mode: Elicitation.Mode? = nil,
requestedSchema: Elicitation.RequestSchema? = nil,
requestedSchema: Elicitation.RequestSchema = .init(),
_meta: Metadata? = nil
) {
self.message = message
@ -188,7 +188,7 @@ extension CreateElicitation.Parameters: Codable {
} else {
// Form mode (default for backward compatibility)
let message = try container.decode(String.self, forKey: .message)
let requestedSchema = try container.decodeIfPresent(
let requestedSchema = try container.decode(
Elicitation.RequestSchema.self, forKey: .requestedSchema)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .form(FormParameters(

View file

@ -16,30 +16,37 @@ public struct Root: Hashable, Codable, Sendable {
public let uri: String
/// Optional human-readable name for the root
public let name: String?
/// Optional metadata
public var _meta: Metadata?
public init(
uri: String,
name: String? = nil
name: String? = nil,
_meta: Metadata? = nil
) {
self.uri = uri
self.name = name
self._meta = _meta
}
private enum CodingKeys: String, CodingKey {
case uri
case name
case _meta
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
uri = try container.decode(String.self, forKey: .uri)
name = try container.decodeIfPresent(String.self, forKey: .name)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(uri, forKey: .uri)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
}

View file

@ -7,7 +7,7 @@ import Foundation
/// - SeeAlso: https://modelcontextprotocol.io/docs/concepts/sampling#how-sampling-works
public enum Sampling {
/// A message in the conversation history.
public struct Message: Hashable, Codable, Sendable {
public struct Message: Hashable, Sendable {
/// The message role
public enum Role: String, Hashable, Codable, Sendable {
/// A user message
@ -20,30 +20,34 @@ public enum Sampling {
public let role: Role
/// The message content
public let content: Content
/// Optional metadata
public var _meta: Metadata?
/// Creates a message with the specified role and content
@available(
*, deprecated, message: "Use static factory methods .user(_:) or .assistant(_:) instead"
)
public init(role: Role, content: Content) {
public init(role: Role, content: Content, _meta: Metadata? = nil) {
self.role = role
self.content = content
self._meta = _meta
}
/// Private initializer for convenience methods to avoid deprecation warnings
private init(_role role: Role, _content content: Content) {
private init(_role role: Role, _content content: Content, _meta: Metadata? = nil) {
self.role = role
self.content = content
self._meta = _meta
}
/// Creates a user message with the specified content
public static func user(_ content: Content) -> Message {
return Message(_role: .user, _content: content)
public static func user(_ content: Content, _meta: Metadata? = nil) -> Message {
return Message(_role: .user, _content: content, _meta: _meta)
}
/// Creates an assistant message with the specified content
public static func assistant(_ content: Content) -> Message {
return Message(_role: .assistant, _content: content)
public static func assistant(_ content: Content, _meta: Metadata? = nil) -> Message {
return Message(_role: .assistant, _content: content, _meta: _meta)
}
/// Content types for sampling messages
@ -144,16 +148,25 @@ public enum Sampling {
case allServers
}
/// Stop reason for sampling completion
public enum StopReason: String, Hashable, Codable, Sendable {
/// Stop reason for sampling completion.
///
/// The spec defines this as an open string — any provider-specific value is valid.
/// The well-known values are exposed as static constants.
public struct StopReason: RawRepresentable, Hashable, Codable, Sendable,
ExpressibleByStringLiteral
{
public let rawValue: String
public init(rawValue: String) { self.rawValue = rawValue }
public init(stringLiteral value: String) { self.rawValue = value }
/// Natural end of turn
case endTurn
public static let endTurn = StopReason(rawValue: "endTurn")
/// Hit a stop sequence
case stopSequence
public static let stopSequence = StopReason(rawValue: "stopSequence")
/// Reached maximum tokens
case maxTokens
public static let maxTokens = StopReason(rawValue: "maxTokens")
/// Model wants to use a tool
case toolUse
public static let toolUse = StopReason(rawValue: "toolUse")
}
/// Content representing a tool use request from the model
@ -220,6 +233,26 @@ public enum Sampling {
// MARK: - Codable
extension Sampling.Message: Codable {
private enum CodingKeys: String, CodingKey {
case role, content, _meta
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
role = try container.decode(Role.self, forKey: .role)
content = try container.decode(Content.self, forKey: .content)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(role, forKey: .role)
try container.encode(content, forKey: .content)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
}
extension Sampling.Message.Content.ContentBlock: Codable {
private enum CodingKeys: String, CodingKey {
case type, text, data, mimeType
@ -243,13 +276,13 @@ extension Sampling.Message.Content.ContentBlock: Codable {
let data = try container.decode(String.self, forKey: .data)
let mimeType = try container.decode(String.self, forKey: .mimeType)
self = .audio(data: data, mimeType: mimeType)
case "toolUse":
case "tool_use":
let id = try container.decode(String.self, forKey: .id)
let name = try container.decode(String.self, forKey: .name)
let input = try container.decode([String: Value].self, forKey: .input)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .toolUse(Sampling.ToolUseContent(id: id, name: name, input: input, _meta: _meta))
case "toolResult":
case "tool_result":
let toolUseId = try container.decode(String.self, forKey: .toolUseId)
let content = try container.decode([Sampling.ToolResultContent.ContentBlock].self, forKey: .content)
let structuredContent = try container.decodeIfPresent([String: Value].self, forKey: .structuredContent)
@ -284,13 +317,13 @@ extension Sampling.Message.Content.ContentBlock: Codable {
try container.encode(data, forKey: .data)
try container.encode(mimeType, forKey: .mimeType)
case .toolUse(let toolUse):
try container.encode("toolUse", forKey: .type)
try container.encode("tool_use", forKey: .type)
try container.encode(toolUse.id, forKey: .id)
try container.encode(toolUse.name, forKey: .name)
try container.encode(toolUse.input, forKey: .input)
try container.encodeIfPresent(toolUse._meta, forKey: ._meta)
case .toolResult(let toolResult):
try container.encode("toolResult", forKey: .type)
try container.encode("tool_result", forKey: .type)
try container.encode(toolResult.toolUseId, forKey: .toolUseId)
try container.encode(toolResult.content, forKey: .content)
try container.encodeIfPresent(toolResult.structuredContent, forKey: .structuredContent)
@ -372,7 +405,7 @@ extension Sampling.ToolResultContent.ContentBlock: Codable {
let annotations = try container.decodeIfPresent(Resource.Annotations.self, forKey: .annotations)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .resource(resource: resourceContent, annotations: annotations, _meta: _meta)
case "resourceLink":
case "resource_link":
let uri = try container.decode(String.self, forKey: .uri)
let name = try container.decode(String.self, forKey: .name)
let title = try container.decodeIfPresent(String.self, forKey: .title)
@ -410,7 +443,7 @@ extension Sampling.ToolResultContent.ContentBlock: Codable {
try container.encodeIfPresent(annotations, forKey: .annotations)
try container.encodeIfPresent(_meta, forKey: ._meta)
case .resourceLink(let uri, let name, let title, let description, let mimeType, let annotations):
try container.encode("resourceLink", forKey: .type)
try container.encode("resource_link", forKey: .type)
try container.encode(uri, forKey: .uri)
try container.encode(name, forKey: .name)
try container.encodeIfPresent(title, forKey: .title)
@ -440,12 +473,28 @@ public enum CreateSamplingMessage: Method {
case none
}
/// The tool choice mode
public let mode: Mode
/// The tool choice mode. If omitted, defaults to `.auto`.
public let mode: Mode?
public init(mode: Mode) {
public init(mode: Mode? = .auto) {
self.mode = mode
}
private enum CodingKeys: String, CodingKey {
case mode
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
mode = try container.decodeIfPresent(Mode.self, forKey: .mode) ?? .auto
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
if let mode, mode != .auto {
try container.encode(mode, forKey: .mode)
}
}
}
public struct Parameters: Hashable, Codable, Sendable {
@ -469,6 +518,8 @@ public enum CreateSamplingMessage: Method {
public let tools: [Tool]?
/// Tool choice configuration
public let toolChoice: ToolChoice?
/// Provider-specific metadata to pass to the LLM
public let metadata: [String: Value]?
public init(
messages: [Sampling.Message],
@ -480,7 +531,8 @@ public enum CreateSamplingMessage: Method {
stopSequences: [String]? = nil,
_meta: Metadata? = nil,
tools: [Tool]? = nil,
toolChoice: ToolChoice? = nil
toolChoice: ToolChoice? = nil,
metadata: [String: Value]? = nil
) {
self.messages = messages
self.modelPreferences = modelPreferences
@ -492,6 +544,7 @@ public enum CreateSamplingMessage: Method {
self._meta = _meta
self.tools = tools
self.toolChoice = toolChoice
self.metadata = metadata
}
}