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
23 changes: 12 additions & 11 deletions Sources/Swarm/MCP/HTTPMCPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ public actor HTTPMCPServer: MCPServer {
timeout: TimeInterval = 30.0,
maxRetries: Int = 3,
session: URLSession = .shared
) {
) throws {
// Security: Enforce HTTPS when API keys are used to prevent credential exposure
if apiKey != nil {
precondition(
url.scheme?.lowercased() == "https",
"HTTPMCPServer: HTTPS is required when using API keys to prevent credential exposure. URL scheme: \(url.scheme ?? "nil")"
)
guard url.scheme?.lowercased() == "https" else {
throw MCPError.invalidRequest(
"HTTPMCPServer: HTTPS is required when using API keys to prevent credential exposure. URL scheme: \(url.scheme ?? "nil")"
)
}
}

baseURL = url
Expand Down Expand Up @@ -120,7 +121,7 @@ public actor HTTPMCPServer: MCPServer {
])
]

let request = MCPRequest(method: "initialize", params: params)
let request = try MCPRequest(method: "initialize", params: params)
let response = try await sendRequest(request)

if let error = response.error {
Expand All @@ -141,7 +142,7 @@ public actor HTTPMCPServer: MCPServer {
/// - Returns: An array of tool schemas describing available tools.
/// - Throws: `MCPError` if the request fails.
public func listTools() async throws -> [ToolSchema] {
let request = MCPRequest(method: "tools/list")
let request = try MCPRequest(method: "tools/list")
let response = try await sendRequest(request)

if let error = response.error {
Expand All @@ -168,7 +169,7 @@ public actor HTTPMCPServer: MCPServer {
"arguments": .dictionary(arguments)
]

let request = MCPRequest(method: "tools/call", params: params)
let request = try MCPRequest(method: "tools/call", params: params)
let response = try await sendRequest(request)

if let error = response.error {
Expand All @@ -187,7 +188,7 @@ public actor HTTPMCPServer: MCPServer {
/// - Returns: An array of resource metadata objects.
/// - Throws: `MCPError` if the request fails.
public func listResources() async throws -> [MCPResource] {
let request = MCPRequest(method: "resources/list")
let request = try MCPRequest(method: "resources/list")
let response = try await sendRequest(request)

if let error = response.error {
Expand All @@ -211,7 +212,7 @@ public actor HTTPMCPServer: MCPServer {
"uri": .string(uri)
]

let request = MCPRequest(method: "resources/read", params: params)
let request = try MCPRequest(method: "resources/read", params: params)
let response = try await sendRequest(request)

if let error = response.error {
Expand Down Expand Up @@ -522,7 +523,7 @@ public actor HTTPMCPServer: MCPServer {
let text = extractString(contentDict["text"])
let blob = extractString(contentDict["blob"])

return MCPResourceContent(
return try MCPResourceContent(
uri: uri,
mimeType: mimeType,
text: text,
Expand Down
63 changes: 24 additions & 39 deletions Sources/Swarm/MCP/MCPProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ import Foundation
/// ## Example Usage
/// ```swift
/// // Simple request without parameters
/// let request = MCPRequest(method: "tools/list")
/// let request = try MCPRequest(method: "tools/list")
///
/// // Request with parameters
/// let callRequest = MCPRequest(
/// let callRequest = try MCPRequest(
/// method: "tools/call",
/// params: [
/// "name": "calculator",
Expand All @@ -34,7 +34,7 @@ import Foundation
/// )
///
/// // Request with custom ID
/// let customRequest = MCPRequest(
/// let customRequest = try MCPRequest(
/// id: "request-001",
/// method: "resources/read",
/// params: ["uri": "file:///example.txt"]
Expand Down Expand Up @@ -76,39 +76,26 @@ public struct MCPRequest: Sendable, Codable, Equatable {
///
/// - Parameters:
/// - id: A unique identifier for the request. Defaults to a new UUID string.
/// Must be non-empty per JSON-RPC 2.0 specification.
/// An empty string is replaced with a fresh UUID automatically.
/// - method: The name of the method to invoke. Must be non-empty.
/// - params: Optional parameters for the method. Defaults to `nil`.
///
/// Creates a new JSON-RPC 2.0 request.
///
/// - Parameters:
/// - id: A unique identifier for the request. Defaults to a new UUID string.
/// Must be non-empty per JSON-RPC 2.0 specification.
/// - method: The name of the method to invoke. Must be non-empty.
/// - params: Optional parameters for the method. Defaults to `nil`.
/// - Precondition: `id` must be non-empty.
/// - Precondition: `method` must be non-empty. Passing an empty method is always
/// a programmer error; the framework does not silently substitute a sentinel value.
/// - Throws: `MCPError.invalidRequest` if `method` is empty.
public init(
id: String = UUID().uuidString,
method: String,
params: [String: SendableValue]? = nil
) {
) throws {
// Validate id — generate a fresh UUID if caller passed empty string.
let validatedId = id.isEmpty ? UUID().uuidString : id

// Validate method — empty method produces an invalid JSON-RPC 2.0 request.
// Assert in debug builds to catch programming errors early; warn in release.
assert(!method.isEmpty, "MCPRequest: method must be non-empty per JSON-RPC 2.0")
if method.isEmpty {
Log.agents.warning("MCPRequest: method was empty; defaulting to 'unknown'. This produces an invalid JSON-RPC 2.0 request.")
// Validate method — empty method is invalid per JSON-RPC 2.0.
guard !method.isEmpty else {
throw MCPError.invalidRequest("MCPRequest: method must be non-empty per JSON-RPC 2.0")
}
let validatedMethod = method.isEmpty ? "unknown" : method

jsonrpc = "2.0"
self.id = validatedId
self.method = validatedMethod
self.method = method
self.params = params
}

Expand Down Expand Up @@ -342,16 +329,15 @@ public extension MCPResponse {
/// Creates a successful response with the given result.
///
/// - Parameters:
/// - id: The identifier matching the corresponding request.
/// - id: The identifier matching the corresponding request. Must be non-empty.
/// - result: The result value to include in the response.
/// - Returns: An MCPResponse with the result set and error as `nil`.
///
/// - Precondition: `id` must be non-empty. Passing an empty string is a
/// programming error and will terminate the process in both debug and
/// release builds via `precondition`.
static func success(id: String, result: SendableValue) -> MCPResponse {
precondition(!id.isEmpty, "MCPResponse.success requires a non-empty id")
MCPResponse(
/// - Throws: `MCPError.invalidRequest` if `id` is empty.
static func success(id: String, result: SendableValue) throws -> MCPResponse {
guard !id.isEmpty else {
throw MCPError.invalidRequest("MCPResponse.success: id must be non-empty")
}
return MCPResponse(
id: id,
result: result,
error: nil
Expand All @@ -361,16 +347,15 @@ public extension MCPResponse {
/// Creates an error response with the given error object.
///
/// - Parameters:
/// - id: The identifier matching the corresponding request.
/// - id: The identifier matching the corresponding request. Must be non-empty.
/// - error: The error object describing what went wrong.
/// - Returns: An MCPResponse with the error set and result as `nil`.
///
/// - Precondition: `id` must be non-empty. Passing an empty string is a
/// programming error and will terminate the process in both debug and
/// release builds via `precondition`.
static func failure(id: String, error: MCPErrorObject) -> MCPResponse {
precondition(!id.isEmpty, "MCPResponse.failure requires a non-empty id")
MCPResponse(
/// - Throws: `MCPError.invalidRequest` if `id` is empty.
static func failure(id: String, error: MCPErrorObject) throws -> MCPResponse {
guard !id.isEmpty else {
throw MCPError.invalidRequest("MCPResponse.failure: id must be non-empty")
}
return MCPResponse(
id: id,
result: nil,
error: error
Expand Down
42 changes: 41 additions & 1 deletion Sources/Swarm/MCP/MCPResource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,57 @@ public struct MCPResourceContent: Sendable, Codable, Equatable {
/// - mimeType: The optional MIME type of the content. Default: nil
/// - text: The text content, if available. Default: nil
/// - blob: The Base64-encoded binary content, if available. Default: nil
/// - Throws: `MCPError.invalidRequest` when both `text` and `blob` are set,
/// or when both are `nil`.
public init(
uri: String,
mimeType: String? = nil,
text: String? = nil,
blob: String? = nil
) {
) throws {
if text != nil, blob != nil {
throw MCPError.invalidRequest("MCPResourceContent: both text and blob cannot be set simultaneously.")
}
if text == nil, blob == nil {
throw MCPError.invalidRequest("MCPResourceContent: at least one of text or blob must be provided.")
}
self.uri = uri
self.mimeType = mimeType
self.text = text
self.blob = blob
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
uri = try container.decode(String.self, forKey: .uri)
mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
text = try container.decodeIfPresent(String.self, forKey: .text)
blob = try container.decodeIfPresent(String.self, forKey: .blob)

if text != nil, blob != nil {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "MCPResourceContent: both text and blob cannot be set simultaneously."
)
)
}
if text == nil, blob == nil {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "MCPResourceContent: at least one of text or blob must be provided."
)
)
}
}

private enum CodingKeys: String, CodingKey {
case uri
case mimeType
case text
case blob
}
}

// MARK: - MCPResource + CustomDebugStringConvertible
Expand Down
16 changes: 9 additions & 7 deletions Sources/Swarm/Orchestration/DAGWorkflow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ public struct DAGBuilder {
public struct DAG: OrchestrationStep, Sendable {
/// The nodes comprising this DAG.
public let nodes: [DAGNode]
private let validationError: DAGValidationError?
/// Topologically sorted nodes, cached at init time to avoid recomputing on every execute() call.
private let sortedNodes: [DAGNode]

Expand All @@ -129,16 +128,19 @@ public struct DAG: OrchestrationStep, Sendable {

/// Creates a new DAG workflow from a builder closure.
///
/// Validates the graph structure at construction time.
/// Validates the graph structure at construction time. Throws if the graph
/// is empty, contains duplicate node names, has missing dependencies, or contains cycles.
///
/// - Parameter content: A builder closure producing DAG nodes.
public init(@DAGBuilder _ content: () -> [DAGNode]) {
/// - Throws: `OrchestrationError.invalidGraph` if the graph is structurally invalid.
public init(@DAGBuilder _ content: () -> [DAGNode]) throws {
let builtNodes = content()
self.validationError = DAG.validate(builtNodes)
if let error = DAG.validate(builtNodes) {
throw OrchestrationError.invalidGraph(error)
}
self.nodes = builtNodes
let (error, sorted) = DAG.validate(builtNodes)
self.validationError = error
self.sortedNodes = sorted
self.validationError = nil
self.sortedNodes = DAG.topologicalSort(builtNodes)
}

/// Internal initializer for testing with pre-validated nodes.
Expand Down
14 changes: 7 additions & 7 deletions Sources/Swarm/Orchestration/OrchestrationError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public enum OrchestrationError: Error, Sendable, Equatable {
/// Route condition is invalid or cannot be evaluated.
case invalidRouteCondition(reason: String)

/// Workflow structure is invalid (for example empty graphs or cyclic dependencies).
case invalidWorkflow(reason: String)

// MARK: - Parallel Execution Errors

/// Merge strategy failed to combine parallel agent results.
Expand All @@ -57,9 +60,6 @@ public enum OrchestrationError: Error, Sendable, Equatable {

/// Human approval was rejected.
case humanApprovalRejected(prompt: String, reason: String)

/// Workflow definition is invalid or cannot be executed.
case invalidWorkflow(reason: String)
}

// MARK: LocalizedError
Expand All @@ -79,6 +79,8 @@ extension OrchestrationError: LocalizedError {
return "Routing decision failed: \(reason)"
case let .invalidRouteCondition(reason):
return "Invalid route condition: \(reason)"
case let .invalidWorkflow(reason):
return "Invalid workflow: \(reason)"
case let .mergeStrategyFailed(reason):
return "Merge strategy failed: \(reason)"
case let .allAgentsFailed(errors):
Expand All @@ -94,8 +96,6 @@ extension OrchestrationError: LocalizedError {
return "Human approval timed out for: \(prompt)"
case let .humanApprovalRejected(prompt, reason):
return "Human approval rejected for '\(prompt)': \(reason)"
case let .invalidWorkflow(reason):
return "Invalid workflow: \(reason)"
}
}
}
Expand All @@ -117,6 +117,8 @@ extension OrchestrationError: CustomDebugStringConvertible {
return "OrchestrationError.routingFailed(reason: \(reason))"
case let .invalidRouteCondition(reason):
return "OrchestrationError.invalidRouteCondition(reason: \(reason))"
case let .invalidWorkflow(reason):
return "OrchestrationError.invalidWorkflow(reason: \(reason))"
case let .mergeStrategyFailed(reason):
return "OrchestrationError.mergeStrategyFailed(reason: \(reason))"
case let .allAgentsFailed(errors):
Expand All @@ -131,8 +133,6 @@ extension OrchestrationError: CustomDebugStringConvertible {
return "OrchestrationError.humanApprovalTimeout(prompt: \(prompt))"
case let .humanApprovalRejected(prompt, reason):
return "OrchestrationError.humanApprovalRejected(prompt: \(prompt), reason: \(reason))"
case let .invalidWorkflow(reason):
return "OrchestrationError.invalidWorkflow(reason: \(reason))"
}
}
}
2 changes: 1 addition & 1 deletion Tests/SwarmTests/Integration/MCPIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ actor IntegrationTestMCPServer: MCPServer {
func listResources() async throws -> [MCPResource] { [] }

func readResource(uri: String) async throws -> MCPResourceContent {
MCPResourceContent(uri: uri)
try MCPResourceContent(uri: uri, text: "resource-content-for-\(uri)")
}

func close() async throws {}
Expand Down
Loading