-
Notifications
You must be signed in to change notification settings - Fork 153
Preserve query in endpoint URL sent by SSE transport #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Preserve query in endpoint URL sent by SSE transport #156
Conversation
| endpoint: URL(string: "http://localhost:8080/sse")! // Ensure endpoint is SSE-specific if needed | ||
| ) | ||
| try await client.connect(transport: sseTransport) | ||
| try await client.initialize() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is no longer required, as of #100
| try await client.initialize() |
| if let pathComponents = URLComponents(string: pathToUse) { | ||
| components.path = pathComponents.path | ||
| // Preserve any query parameters from the endpoint path | ||
| if let queryItems = pathComponents.queryItems { | ||
| components.queryItems = queryItems | ||
| } | ||
| } else { | ||
| components.path = pathToUse | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slightly more comprehensive version:
| if let pathComponents = URLComponents(string: pathToUse) { | |
| components.path = pathComponents.path | |
| // Preserve any query parameters from the endpoint path | |
| if let queryItems = pathComponents.queryItems { | |
| components.queryItems = queryItems | |
| } | |
| } else { | |
| components.path = pathToUse | |
| } | |
| // For relative paths, preserve the scheme, host, and port. | |
| let pathToUse = path.starts(with: "/") ? path : "/\(path)" | |
| if let relativeComponents = URLComponents(string: pathToUse) { | |
| // Set path (prefer percentEncodedPath if present to avoid double-encoding) | |
| if let percentEncodedPath = relativeComponents.percentEncodedPath.isEmpty | |
| ? nil : relativeComponents.percentEncodedPath | |
| { | |
| components.percentEncodedPath = percentEncodedPath | |
| } else { | |
| components.path = relativeComponents.path | |
| } | |
| // Set query via queryItems to ensure correct percent-encoding | |
| if let queryItems = relativeComponents.queryItems, !queryItems.isEmpty { | |
| components.queryItems = queryItems | |
| } else { | |
| components.queryItems = nil | |
| } | |
| // Preserve fragment if provided | |
| components.fragment = relativeComponents.fragment | |
| } else { | |
| // Fallback: set path directly if components parsing fails | |
| components.path = pathToUse | |
| } | |
|
Hi @jalingo. Thanks for your contribution. Making sure I correctly understand the motivation behind this PR: You're using query parameters (e.g. |
|
We should give this new behavior some test coverage. Here are some tests that pass with the code I suggested above: @Test("Query parameter encoding in relative endpoint URL", .sseTransportSetup)
func testQueryEncodingInRelativeEndpoint() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockSSEURLProtocol.self]
let transport = SSEClientTransport(
endpoint: testEndpoint,
configuration: configuration
)
let rawEndpoint =
"/messages/with space?q=hello world&sym=%&plus=+&slash=a/b&emoji=😀#frag"
let capturedRequest = CapturedRequest()
await MockSSEURLProtocol.setHandler { request in
if request.httpMethod == "GET" {
let response = HTTPURLResponse(
url: testEndpoint,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/event-stream"]
)!
return (
response,
Data(
"""
event: endpoint
data: \(rawEndpoint)
""".utf8)
)
} else if request.httpMethod == "POST" {
await capturedRequest.setValue(request)
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, Data())
} else {
throw MCPError.internalError("Unexpected request method")
}
}
try await transport.connect()
try await transport.send(Data())
let postReq = await capturedRequest.getValue()
#expect(postReq != nil)
guard let url = postReq?.url,
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
else {
Issue.record("Invalid URL in captured POST request")
return
}
#expect(comps.scheme == testEndpoint.scheme)
#expect(comps.host == testEndpoint.host)
#expect(comps.path == "/messages/with space")
let expectedItems = [
URLQueryItem(name: "q", value: "hello world"),
URLQueryItem(name: "sym", value: "%"),
URLQueryItem(name: "plus", value: "+"),
URLQueryItem(name: "slash", value: "a/b"),
URLQueryItem(name: "emoji", value: "😀"),
]
#expect(comps.queryItems?.count == expectedItems.count)
if let items = comps.queryItems {
for (idx, item) in items.enumerated() {
#expect(item.name == expectedItems[idx].name)
#expect(item.value == expectedItems[idx].value)
}
}
#expect(comps.fragment == "frag")
await transport.disconnect()
}
@Test("No double-encoding for already percent-encoded inputs", .sseTransportSetup)
func testNoDoubleEncoding() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockSSEURLProtocol.self]
let transport = SSEClientTransport(
endpoint: testEndpoint,
configuration: configuration
)
let rawEndpoint = "/messages/%E2%9C%93?q=a%2Bb"
let capturedRequest = CapturedRequest()
await MockSSEURLProtocol.setHandler { request in
if request.httpMethod == "GET" {
let response = HTTPURLResponse(
url: testEndpoint,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/event-stream"]
)!
return (
response,
Data(
"""
event: endpoint
data: \(rawEndpoint)
""".utf8)
)
} else if request.httpMethod == "POST" {
await capturedRequest.setValue(request)
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, Data())
} else {
throw MCPError.internalError("Unexpected request method")
}
}
try await transport.connect()
try await transport.send(Data())
guard let url = await capturedRequest.getValue()?.url,
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
else {
Issue.record("Invalid URL in captured POST request")
return
}
#expect(comps.path == "/messages/✓")
#expect(comps.queryItems?.first(where: { $0.name == "q" })?.value == "a+b")
await transport.disconnect()
}
} |
|
Actually, just found out I'm not allowed to contribute to open source anymore; but when the endpoint was delivered for all future posts it was getting malformed. Also not my experience with the init, but maybe that was added to the branch recently. |
URL was formatting incorrectly, preventing endpoint from being recognized after sse connection.
Motivation and Context
Wanted SSE connections to mcp services to work.
How Has This Been Tested?
It works with multiple sse mcp services now.
Breaking Changes
Don't think so.
Types of changes
Checklist
Additional context
Pretty straightforward