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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
# Current leg: catches breakage from new Xcode and SDK releases.
- os: macos-latest
swift-version: Xcode default
- os: ubuntu-latest
- os: ubuntu-24.04
swift-version: 6.1.0

runs-on: ${{ matrix.os }}
Expand All @@ -35,13 +35,13 @@ jobs:
- uses: actions/checkout@v4

- name: Prepare Linux apt dependencies
if: matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-24.04'
run: |
sudo -n apt-get update
sudo -n apt-get install -y --no-install-recommends libcurl4-openssl-dev

- name: Setup Swift on Linux
if: matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-24.04'
uses: vapor/swiftly-action@v0.2
with:
toolchain: ${{ matrix.swift-version }}
Expand Down
41 changes: 41 additions & 0 deletions Sources/MCP/Base/Transports/HTTPClientTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ public actor HTTPClientTransport: Transport {
)
request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
request.httpBody = data
addStandardRequestHeaders(to: &request, body: data)

if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
Expand Down Expand Up @@ -304,6 +305,46 @@ public actor HTTPClientTransport: Transport {
}
}

/// Mirrors routing fields from a JSON-RPC request or notification into HTTP headers.
/// Responses and legacy batches have no single method to mirror.
private func addStandardRequestHeaders(to request: inout URLRequest, body: Data) {
guard let message = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
let method = message["method"] as? String,
isSafeHTTPHeaderValue(method)
else { return }

request.setValue(method, forHTTPHeaderField: HTTPHeaderName.method)

let nameKey: String
switch method {
case "tools/call", "prompts/get": nameKey = "name"
case "resources/read": nameKey = "uri"
default: return
}

guard let params = message["params"] as? [String: Any],
let name = params[nameKey] as? String
else { return }

let headerValue: String
if isSafeHTTPHeaderValue(name),
!(name.hasPrefix("=?base64?") && name.hasSuffix("?="))
{
headerValue = name
} else {
headerValue = "=?base64?\(Data(name.utf8).base64EncodedString())?="
}
request.setValue(headerValue, forHTTPHeaderField: HTTPHeaderName.name)
}

private func isSafeHTTPHeaderValue(_ value: String) -> Bool {
let bytes = value.utf8
guard let first = bytes.first, let last = bytes.last,
first != 0x20, first != 0x09, last != 0x20, last != 0x09
else { return false }
return bytes.allSatisfy { (0x20...0x7E).contains($0) }
}

#if os(Linux)
private func processResponse(response: URLResponse, data: Data) async throws {
guard let httpResponse = response as? HTTPURLResponse else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ public enum HTTPResponse: Sendable {
public enum HTTPHeaderName {
public static let sessionID = "MCP-Session-Id"
public static let protocolVersion = "MCP-Protocol-Version"
public static let method = "Mcp-Method"
public static let name = "Mcp-Name"
public static let lastEventID = "Last-Event-ID"
public static let accept = "Accept"
public static let contentType = "Content-Type"
Expand Down
60 changes: 20 additions & 40 deletions Tests/MCPTests/ClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -124,10 +122,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -174,10 +170,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -230,10 +224,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -321,10 +313,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -401,10 +391,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -481,10 +469,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -521,10 +507,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -656,10 +640,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -717,10 +699,8 @@ struct ClientTests {

// Set up a task to handle the initialize response
let initTask = Task {
try await Task.sleep(for: .milliseconds(10))
if let lastMessage = await transport.sentMessages.last,
let data = lastMessage.data(using: .utf8),
let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down
48 changes: 48 additions & 0 deletions Tests/MCPTests/HTTPClientTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,54 @@ import Testing
#expect(receivedData == responseData)
}

@Test("POST request headers mirror the JSON-RPC method and name", .httpClientTransportSetup)
func testStandardRequestHeaders() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]

let transport = HTTPClientTransport(
endpoint: testEndpoint,
configuration: configuration,
streaming: false
)
try await transport.connect()

let cases: [(body: String, method: String?, name: String?)] = [
(#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#, "initialize", nil),
(#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
"notifications/initialized", nil),
(#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"weather"}}"#,
"tools/call", "weather"),
(#"{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"hello world"}}"#,
"prompts/get", "hello world"),
(#"{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"file:///資料"}}"#,
"resources/read", "=?base64?ZmlsZTovLy/os4fmlpk=?="),
(#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"=?base64?abc?="}}"#,
"tools/call", "=?base64?PT9iYXNlNjQ/YWJjPz0=?="),
(#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":" weather "}}"#,
"tools/call", "=?base64?IHdlYXRoZXIg?="),
(#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"foo\tbar"}}"#,
"tools/call", "=?base64?Zm9vCWJhcg==?="),
(#"{"jsonrpc":"2.0","id":7,"result":{}}"#, nil, nil),
]

for testCase in cases {
await MockURLProtocol.requestHandlerStorage.setHandler {
[testEndpoint] request in
#expect(request.url == testEndpoint)
#expect(request.value(forHTTPHeaderField: "Mcp-Method") == testCase.method)
#expect(request.value(forHTTPHeaderField: "Mcp-Name") == testCase.name)

let response = HTTPURLResponse(
url: testEndpoint, statusCode: 202, httpVersion: "HTTP/1.1",
headerFields: [:])!
return (response, Data())
}
try await transport.send(Data(testCase.body.utf8))
}
await transport.disconnect()
}

@Test("Send and Receive Session ID", .httpClientTransportSetup)
func testSendAndReceiveSessionID() async throws {
let configuration = URLSessionConfiguration.ephemeral
Expand Down
15 changes: 15 additions & 0 deletions Tests/MCPTests/Helpers/MockTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ actor MockTransport: Transport {
var isConnected = false

private(set) var sentData: [Data] = []
private var firstSendWaiters: [CheckedContinuation<Data, Never>] = []
var sentMessages: [String] {
return sentData.compactMap { data in
guard let string = String(data: data, encoding: .utf8) else {
Expand Down Expand Up @@ -57,6 +58,20 @@ actor MockTransport: Transport {
throw MCPError.transportError(POSIXError(.EIO))
}
sentData.append(message)
let waiters = firstSendWaiters
firstSendWaiters.removeAll()
for waiter in waiters {
waiter.resume(returning: message)
}
}

func waitForFirstSentData() async -> Data {
if let data = sentData.first {
return data
}
return await withCheckedContinuation { continuation in
firstSendWaiters.append(continuation)
}
}

public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
Expand Down
Loading