diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e955d0..0a03d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [0.6.1] - 2026-10-29 + +### Fixed + +- Preserve legacy fixture tool results while their SSE response stream is + disconnected so clients can recover them with `Last-Event-ID`. Validate + delayed resumption repeatedly before publishing fixture images. This removes + catalog-order-dependent header-validation timeouts and the fixture's SSE + polling timeout. + ## [0.6.0] - 2026-10-14 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 15e6cac..5d7ab97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,7 +201,7 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "aws-lc-rs", diff --git a/Cargo.toml b/Cargo.toml index 8b58a84..11cad4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.6.0" +version = "0.6.1" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/docker/mcp-conformance-server.Dockerfile b/docker/mcp-conformance-server.Dockerfile index adbb4c3..3e11206 100644 --- a/docker/mcp-conformance-server.Dockerfile +++ b/docker/mcp-conformance-server.Dockerfile @@ -14,6 +14,11 @@ RUN git clone https://github.com/modelcontextprotocol/conformance.git mcp-confor WORKDIR /opt/mcp-conformance/examples/servers/typescript RUN npm ci +COPY docker/mcp-typescript-sdk-resumption.patch /tmp/mcp-typescript-sdk-resumption.patch +RUN test "$(node -p "require('./node_modules/@modelcontextprotocol/sdk/package.json').version")" = "1.29.0" \ + && git -C /opt/mcp-conformance apply --check --directory=examples/servers/typescript/node_modules/@modelcontextprotocol/sdk /tmp/mcp-typescript-sdk-resumption.patch \ + && git -C /opt/mcp-conformance apply --directory=examples/servers/typescript/node_modules/@modelcontextprotocol/sdk /tmp/mcp-typescript-sdk-resumption.patch + COPY docker/mcp-conformance.patch /tmp/mcp-conformance.patch RUN git apply --check /tmp/mcp-conformance.patch && git apply /tmp/mcp-conformance.patch diff --git a/docker/mcp-typescript-sdk-resumption.patch b/docker/mcp-typescript-sdk-resumption.patch new file mode 100644 index 0000000..c0884be --- /dev/null +++ b/docker/mcp-typescript-sdk-resumption.patch @@ -0,0 +1,30 @@ +--- a/dist/esm/server/webStandardStreamableHttp.js ++++ b/dist/esm/server/webStandardStreamableHttp.js +@@ -699,14 +699,16 @@ + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } + const stream = this._streamMapping.get(streamId); +- if (!this._enableJsonResponse && stream?.controller && stream?.encoder) { ++ if (!this._enableJsonResponse) { + // For SSE responses, generate event ID if event store is provided + let eventId; + if (this._eventStore) { + eventId = await this._eventStore.storeEvent(streamId, message); + } + // Write the event to the response stream +- this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); ++ if (stream?.controller && stream?.encoder) { ++ this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); ++ } + } + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._requestResponseMap.set(requestId, message); +@@ -717,7 +719,7 @@ + const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); + if (allResponsesReady) { + if (!stream) { +- throw new Error(`No connection established for request ID: ${String(requestId)}`); ++ return; + } + if (this._enableJsonResponse && stream.resolveJson) { + // All responses ready, send as JSON diff --git a/scripts/smoke_harness_images.py b/scripts/smoke_harness_images.py index 1da4e3f..bf62980 100644 --- a/scripts/smoke_harness_images.py +++ b/scripts/smoke_harness_images.py @@ -5,6 +5,7 @@ import subprocess import time from pathlib import Path +from urllib.request import Request, urlopen def docker(*arguments): @@ -12,6 +13,50 @@ def docker(*arguments): return subprocess.check_output(["docker", *arguments], text=True).strip() +def verify_resumption(container): + """Recover results produced while a legacy response stream is disconnected.""" + address = docker("port", container, "3000/tcp") + url = f"http://{address}/mcp" + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-11-25", + } + + def post(message): + request = Request(url, data=json.dumps(message).encode(), headers=headers) + return urlopen(request, timeout=10) + + with post({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "resumption-smoke", "version": "1"}}, + }) as response: + headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] + response.read() + try: + with post({"jsonrpc": "2.0", "method": "notifications/initialized"}) as response: + assert response.status == 202, response.status + for request_id in range(2, 5): + with post({"jsonrpc": "2.0", "id": request_id, "method": "tools/call", "params": {"name": "test_reconnection", "arguments": {}}}) as response: + priming = response.read().decode() + fields = dict(line.split(":", 1) for line in priming.splitlines() if ":" in line) + event_id = fields["id"].strip() + time.sleep(int(fields["retry"].strip()) / 1000) + request = Request(url, headers={**headers, "Last-Event-ID": event_id}) + with urlopen(request, timeout=10) as response: + for line in response: + if line.startswith(b"data:"): + message = json.loads(line[5:]) + break + else: + raise AssertionError("Resumed stream ended without the tool result") + assert message["id"] == request_id, message + assert "Reconnection test completed successfully" in message["result"]["content"][0]["text"], message + finally: + with urlopen(Request(url, headers=headers, method="DELETE"), timeout=10) as response: + assert response.status == 200, response.status + + def main(): """Smoke test images before their release tags are published.""" version = os.environ["IMAGE_VERSION"] @@ -36,7 +81,7 @@ def main(): docker("run", "--rm", "--entrypoint", "mcp-inspector", f"{prefix}tools:{version}", "--help") for era in ("legacy", "modern", "dual"): - container = docker("run", "--detach", "--env", f"MCP_CONFORMANCE_SERVER_ERA={era}", f"{prefix}fixture:{version}") + container = docker("run", "--detach", "--publish", "127.0.0.1::3000", "--env", f"MCP_CONFORMANCE_SERVER_ERA={era}", f"{prefix}fixture:{version}") try: for _ in range(60): result = subprocess.run( @@ -51,6 +96,8 @@ def main(): time.sleep(1) else: raise RuntimeError(f"{era} fixture did not become ready: {docker('logs', container)}") + if era in ("legacy", "dual"): + verify_resumption(container) finally: docker("rm", "--force", container) print("All harness images passed smoke checks") diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs index 843f4fa..29d8a7a 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -70,6 +70,7 @@ static ASSETS: LazyLock> = LazyLock::new(|| { asset!("docker/nginx.cf-dataplane.conf"), asset!("docker/nginx.cf-dataplane-standalone.conf.template"), asset!("docker/mcp-conformance.patch"), + asset!("docker/mcp-typescript-sdk-resumption.patch"), asset!("docker/helpers.Dockerfile"), asset!("docker/controlplane-principal.cel"), asset!("scripts/live_protocol/sitecustomize.py"),