swift-sdk/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift
Maksym Mova 6112a3995a
V0.11.x (#199)
* Support 2025-06-18 Spec (#167)

* Support OpenAI _meta for Tool

* Update init

* Add title and outputSchema

* Concurreny issue

* General fields

* Add Generate Fields to Server elements

* 4 tab spacing

* Update Version

* Elicitation

* Format

* More tests and inits

* Elicitation in README

* Fix orphaned test

* Add title to more structs

* Public init

* Fix meta validation and make tests more generic

* Format and make Result properties public again

* Revert more visibility changes

* feat: 2025-11-25 phase 1, includes Version, Icon, Progress (#181)

* chore: fixed unit tests

* feat: added icon and progess base structs

* feat: added progress notification and meta to callTool

* feat: added tests

* feat: simplified RequestMeta encoding/decoding

* tests: added unit test to track progress

* chore: rebased v0.11.x

* feat: implemented cancellation

* feat: added overloaded callTool method

* fix: encoding Response _meta fix

* chore: added old version to the versions list

* feat: 2025-11-25 phase 2; added Logging and Completions (#196)

* feat: added Logging and Completions

* feat: updated EmbeddedResource for the latest spec

* chore: changed encoding of meta in Prompts to encodeIfPresent

* feat: compliance tests + http server transport (#197)

* feat: http server transport

* feat: added http server transport

* feat: removed unused files

* feat: added conformance tests

* feat: improved conformace testing

* chore: removed redundant files

* feat/2025-11-25: sampling, elicitation, roots (#198)

* feat: Roots implementation

* feat: added unit testing

* feat: improved unit tests and fixed NetworkTransport crash

* feat: implemented sampling and elicitation

* feat: improved sampling tests

* feat: improved elicitation tests

* feat: improved confirmance tests

* feat: improved Prompts unit tests

* feat: removed test code

* feat: updated conformance tests

* chore: removed redundant commas

* chore: removed 6.0.3 swift version from ci

* chore: improved unit tests a little bit

* chore: update README.md

* chore: add server progress tracking example to README.md

* chore: updated README.md

* chore: updated transports table formatting

* chore: updated MockNetworkConnection

---------

Co-authored-by: Austin Evans <austinjevans@me.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-02-19 13:09:31 +02:00

371 lines
13 KiB
Swift

import Foundation
// MARK: - Validation Protocol
/// Validates an incoming HTTP request before the transport processes it.
///
/// Validators are composed into a pipeline and executed in order. The first validator
/// that returns a non-nil response short-circuits the pipeline and that error response
/// is returned to the client.
///
/// Conform to this protocol to add custom validation (e.g., authentication):
/// ```swift
/// struct BearerTokenValidator: HTTPRequestValidator {
/// func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
/// guard let auth = request.header("Authorization"),
/// auth.hasPrefix("Bearer ") else {
/// return .error(statusCode: 401, .invalidRequest("Missing bearer token"))
/// }
/// return nil
/// }
/// }
/// ```
public protocol HTTPRequestValidator: Sendable {
/// Validates the request. Returns an error response if invalid, or `nil` if valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Validation Context
/// Context provided to validators for making validation decisions.
public struct HTTPValidationContext: Sendable {
/// The HTTP method of the request (GET, POST, DELETE).
public let httpMethod: String
/// The current session ID, if any (nil in stateless mode or before initialization).
public let sessionID: String?
/// Whether the request body contains an `initialize` JSON-RPC request.
public let isInitializationRequest: Bool
/// The set of protocol versions this server supports.
public let supportedProtocolVersions: Set<String>
public init(
httpMethod: String,
sessionID: String? = nil,
isInitializationRequest: Bool = false,
supportedProtocolVersions: Set<String> = Version.supported
) {
self.httpMethod = httpMethod
self.sessionID = sessionID
self.isInitializationRequest = isInitializationRequest
self.supportedProtocolVersions = supportedProtocolVersions
}
}
// MARK: - Accept Header Validator
/// Validates the `Accept` header based on the HTTP method and transport response mode.
///
/// - Stateful (SSE) mode: POST requests must accept both `application/json` and `text/event-stream`
/// - Stateless (JSON) mode: POST requests only need to accept `application/json`
/// - GET requests always require `text/event-stream`
public struct AcceptHeaderValidator: HTTPRequestValidator {
/// The response mode determines which content types are required.
public enum Mode: Sendable {
/// POST requires both `application/json` and `text/event-stream`.
case sseRequired
/// POST only requires `application/json`.
case jsonOnly
}
public let mode: Mode
public init(mode: Mode) {
self.mode = mode
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
let accept = request.header(HTTPHeaderName.accept) ?? ""
let acceptTypes = accept.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespaces)
}
let hasJSON = acceptTypes.contains { $0.hasPrefix(ContentType.json) }
let hasSSE = acceptTypes.contains { $0.hasPrefix(ContentType.sse) }
switch context.httpMethod {
case "POST":
switch mode {
case .sseRequired:
guard hasJSON, hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept both application/json and text/event-stream"
),
sessionID: context.sessionID
)
}
case .jsonOnly:
guard hasJSON else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept application/json"
),
sessionID: context.sessionID
)
}
}
case "GET":
guard hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept text/event-stream"
),
sessionID: context.sessionID
)
}
default:
break
}
return nil
}
}
// MARK: - Content-Type Validator
/// Validates that POST requests have `Content-Type: application/json`.
public struct ContentTypeValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard context.httpMethod == "POST" else { return nil }
let contentType = request.header(HTTPHeaderName.contentType) ?? ""
let mainType = contentType.split(separator: ";").first?
.trimmingCharacters(in: .whitespaces) ?? ""
guard mainType == ContentType.json else {
return .error(
statusCode: 415,
.invalidRequest(
"Unsupported Media Type: Content-Type must be application/json"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Protocol Version Validator
/// Validates the `MCP-Protocol-Version` header against supported versions.
///
/// Per spec:
/// - If the header is absent, the server assumes the default negotiated version
/// - If the header is present but unsupported, the server returns 400 Bad Request
/// - Initialization requests are exempt (protocol version comes from the request body)
public struct ProtocolVersionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip for initialization requests (version is in the body, not the header)
guard !context.isInitializationRequest else { return nil }
// Skip for non-POST methods (GET/DELETE don't carry protocol version)
// Actually, per spec, all subsequent requests should include it
guard let version = request.header(HTTPHeaderName.protocolVersion) else {
// Per spec: if not received, assume default version
return nil
}
guard context.supportedProtocolVersions.contains(version) else {
let supported = context.supportedProtocolVersions.sorted().joined(separator: ", ")
return .error(
statusCode: 400,
.invalidRequest(
"Bad Request: Unsupported protocol version: \(version). Supported: \(supported)"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Session Validator
/// Validates the `Mcp-Session-Id` header for stateful transports.
///
/// - Initialization requests are exempt (no session exists yet)
/// - Non-initialization requests must include the session ID header
/// - The session ID must match the active session
public struct SessionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip validation for initialization requests
guard !context.isInitializationRequest else { return nil }
// If no session exists yet, skip (server hasn't been initialized)
guard let expectedSessionID = context.sessionID else { return nil }
let requestSessionID = request.header(HTTPHeaderName.sessionID)
guard let requestSessionID else {
return .error(
statusCode: 400,
.invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header"),
sessionID: expectedSessionID
)
}
guard requestSessionID == expectedSessionID else {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Invalid or expired session ID"),
sessionID: expectedSessionID
)
}
return nil
}
}
// MARK: - Origin Validator
/// DNS rebinding protection: validates `Origin` and `Host` headers.
///
/// Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.
/// This is particularly important for servers running on localhost.
///
/// Use `.localhost()` for local development servers.
/// Use `.disabled` to skip validation (e.g., cloud deployments).
/// Use `init(allowedHosts:allowedOrigins:)` for custom configurations.
public struct OriginValidator: HTTPRequestValidator {
public let allowedHosts: [String]
public let allowedOrigins: [String]
private let enabled: Bool
public init(allowedHosts: [String], allowedOrigins: [String]) {
self.allowedHosts = allowedHosts
self.allowedOrigins = allowedOrigins
self.enabled = true
}
private init(disabled: Void) {
self.allowedHosts = []
self.allowedOrigins = []
self.enabled = false
}
/// Protection for localhost-bound servers.
/// Allows requests from `localhost`, `127.0.0.1`, and `[::1]` with the specified port.
public static func localhost(port: Int? = nil) -> OriginValidator {
let portPattern = port.map { String($0) } ?? "*"
return OriginValidator(
allowedHosts: [
"127.0.0.1:\(portPattern)",
"localhost:\(portPattern)",
"[::1]:\(portPattern)",
],
allowedOrigins: [
"http://127.0.0.1:\(portPattern)",
"http://localhost:\(portPattern)",
"http://[::1]:\(portPattern)",
]
)
}
/// Disables DNS rebinding protection.
/// Use for cloud deployments where DNS rebinding is not a threat.
public static var disabled: OriginValidator {
OriginValidator(disabled: ())
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard enabled else { return nil }
// Validate Host header
if let host = request.header(HTTPHeaderName.host) {
let hostAllowed = allowedHosts.contains { pattern in
matchesPattern(host, pattern: pattern)
}
if !hostAllowed {
return .error(
statusCode: 421,
.invalidRequest("Misdirected Request: Host header not allowed"),
sessionID: context.sessionID
)
}
}
// Validate Origin header (only if present — non-browser clients won't send it)
if let origin = request.header(HTTPHeaderName.origin) {
let originAllowed = allowedOrigins.contains { pattern in
matchesPattern(origin, pattern: pattern)
}
if !originAllowed {
return .error(
statusCode: 403,
.invalidRequest("Forbidden: Origin not allowed"),
sessionID: context.sessionID
)
}
}
return nil
}
/// Matches a value against a pattern that may contain a port wildcard `:*`.
///
/// Examples:
/// - `"localhost:*"` matches `"localhost:8080"`, `"localhost:3000"`
/// - `"http://localhost:*"` matches `"http://localhost:8080"`
/// - `"localhost:8080"` matches only `"localhost:8080"` exactly
private func matchesPattern(_ value: String, pattern: String) -> Bool {
guard pattern.hasSuffix(":*") else {
return value == pattern
}
let prefix = String(pattern.dropLast(2))
guard value.hasPrefix(prefix + ":") else { return false }
let portPart = value.dropFirst(prefix.count + 1)
return !portPart.isEmpty && portPart.allSatisfy(\.isNumber)
}
}
// MARK: - Validation Pipeline Protocol
/// Runs a validation pipeline against an HTTP request.
///
/// Implementations execute a sequence of validators and return the first error,
/// or `nil` if all validations pass.
public protocol HTTPRequestValidationPipeline: Sendable {
/// Validates the request using the configured pipeline.
/// Returns an error response if validation fails, or `nil` if the request is valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Standard Validation Pipeline
/// Standard implementation of `HTTPRequestValidationPipeline` that runs validators in sequence.
///
/// The first validator that returns a non-nil error response short-circuits the pipeline.
public struct StandardValidationPipeline: HTTPRequestValidationPipeline {
private let validators: [any HTTPRequestValidator]
/// Creates a pipeline with the given validators.
/// Validators are executed in the order provided.
public init(validators: [any HTTPRequestValidator]) {
self.validators = validators
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
for validator in validators {
if let errorResponse = validator.validate(request, context: context) {
return errorResponse
}
}
return nil
}
}