diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 26b57443..a5e0f193 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -360,30 +360,7 @@ jobs: MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }} run: | set -euo pipefail - encoded_name="$(python3 - <<'PY' - import os - import urllib.parse - - print(urllib.parse.quote(os.environ["MCP_SERVER_NAME"], safe="")) - PY - )" - curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${encoded_name}" -o /tmp/mcp-registry.json || echo '{"servers":[]}' > /tmp/mcp-registry.json - already_published="$(python3 - <<'PY' - import json - import os - from pathlib import Path - - data = json.loads(Path("/tmp/mcp-registry.json").read_text()) - name = os.environ["MCP_SERVER_NAME"] - version = os.environ["MCP_SERVER_VERSION"] - exists = any( - item.get("server", {}).get("name") == name and - item.get("server", {}).get("version") == version - for item in data.get("servers", []) - ) - print("true" if exists else "false") - PY - )" + already_published="$(node scripts/mcp-registry-version.mjs --check)" echo "already_published=${already_published}" >> "$GITHUB_OUTPUT" - name: Authenticate to MCP Registry @@ -398,33 +375,7 @@ jobs: env: MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }} MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }} - run: | - set -euo pipefail - encoded_name="$(python3 - <<'PY' - import os - import urllib.parse - - print(urllib.parse.quote(os.environ["MCP_SERVER_NAME"], safe="")) - PY - )" - curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${encoded_name}" -o /tmp/mcp-registry.json - python3 - <<'PY' - import json - import os - from pathlib import Path - - data = json.loads(Path("/tmp/mcp-registry.json").read_text()) - name = os.environ["MCP_SERVER_NAME"] - version = os.environ["MCP_SERVER_VERSION"] - - for item in data.get("servers", []): - server = item.get("server", {}) - if server.get("name") == name and server.get("version") == version: - print(f"Verified {name} {version}") - break - else: - raise SystemExit(f"Registry search did not return {name} {version}") - PY + run: node scripts/mcp-registry-version.mjs publish-pypi: needs: release-please @@ -608,30 +559,7 @@ jobs: MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }} run: | set -euo pipefail - encoded_name="$(python3 - <<'PY' - import os - import urllib.parse - - print(urllib.parse.quote(os.environ["MCP_SERVER_NAME"], safe="")) - PY - )" - curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${encoded_name}" -o /tmp/mcp-registry.json || echo '{"servers":[]}' > /tmp/mcp-registry.json - already_published="$(python3 - <<'PY' - import json - import os - from pathlib import Path - - data = json.loads(Path("/tmp/mcp-registry.json").read_text()) - name = os.environ["MCP_SERVER_NAME"] - version = os.environ["MCP_SERVER_VERSION"] - exists = any( - item.get("server", {}).get("name") == name and - item.get("server", {}).get("version") == version - for item in data.get("servers", []) - ) - print("true" if exists else "false") - PY - )" + already_published="$(node scripts/mcp-registry-version.mjs --check)" echo "already_published=${already_published}" >> "$GITHUB_OUTPUT" - name: Authenticate to MCP Registry @@ -665,33 +593,7 @@ jobs: env: MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }} MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }} - run: | - set -euo pipefail - encoded_name="$(python3 - <<'PY' - import os - import urllib.parse - - print(urllib.parse.quote(os.environ["MCP_SERVER_NAME"], safe="")) - PY - )" - curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${encoded_name}" -o /tmp/mcp-registry.json - python3 - <<'PY' - import json - import os - from pathlib import Path - - data = json.loads(Path("/tmp/mcp-registry.json").read_text()) - name = os.environ["MCP_SERVER_NAME"] - version = os.environ["MCP_SERVER_VERSION"] - - for item in data.get("servers", []): - server = item.get("server", {}) - if server.get("name") == name and server.get("version") == version: - print(f"Verified {name} {version}") - break - else: - raise SystemExit(f"Registry search did not return {name} {version}") - PY + run: node scripts/mcp-registry-version.mjs publish-rubygems: needs: release-please diff --git a/package.json b/package.json index 58816d79..572d22b6 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "packageManager": "pnpm@10.22.0", "scripts": { - "build": "pnpm drift:check && pnpm -r --if-present build && pnpm verify:layout && pnpm verify:ts-public-api && pnpm verify:tree-shaking && pnpm test:ts-helpers && pnpm test:ts-ai-sdk && pnpm test:python-release-guardrails && pnpm test:windows-command-shims && pnpm test:chocolatey-asset-tools && pnpm test:chocolatey-workflow-order && pnpm test:snap-workflow && pnpm test:cli-tarball-tools && pnpm check:cli && pnpm check:surface-coverage && pnpm check:live-e2e && pnpm build:go && pnpm build:python && pnpm build:php && pnpm build:ruby && pnpm build:mcp", + "build": "pnpm drift:check && pnpm -r --if-present build && pnpm verify:layout && pnpm verify:ts-public-api && pnpm verify:tree-shaking && pnpm test:ts-helpers && pnpm test:ts-ai-sdk && pnpm test:python-release-guardrails && pnpm test:mcp-registry-version && pnpm test:windows-command-shims && pnpm test:chocolatey-asset-tools && pnpm test:chocolatey-workflow-order && pnpm test:snap-workflow && pnpm test:cli-tarball-tools && pnpm check:cli && pnpm check:surface-coverage && pnpm check:live-e2e && pnpm build:go && pnpm build:python && pnpm build:php && pnpm build:ruby && pnpm build:mcp", "build:go": "pnpm generate:go && node scripts/check-go.mjs && cd go && go test ./... && go vet ./... && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.3.0 run", "build:live-e2e-surfaces": "pnpm drift:check && pnpm -r --if-present build && pnpm build:go && pnpm build:python && pnpm build:php && pnpm build:ruby && pnpm build:mcp", "build:mcp": "pnpm generate:mcp && node scripts/check-mcp.mjs", @@ -42,6 +42,7 @@ "test:ts-helpers": "node scripts/test-ts-helpers.mjs", "test:ts-ai-sdk": "node scripts/test-ts-ai-sdk.mjs", "test:python-release-guardrails": "node scripts/test-python-release-guardrails.mjs", + "test:mcp-registry-version": "node --test scripts/mcp-registry-version.test.mjs", "test:windows-command-shims": "node scripts/test-windows-command-shims.mjs", "verify:codegen": "node scripts/verify-normalized-openapi.mjs", "verify:layout": "node scripts/verify-layout.mjs", diff --git a/packages/python/mcp/sendmux_mcp/a2a.py b/packages/python/mcp/sendmux_mcp/a2a.py index 2c2b0638..fbe867c8 100644 --- a/packages/python/mcp/sendmux_mcp/a2a.py +++ b/packages/python/mcp/sendmux_mcp/a2a.py @@ -28,6 +28,7 @@ SecurityRequirement, SecurityScheme, StringList, + VersionNotSupportedError, ) from fastmcp.server.auth import AccessToken from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator @@ -51,6 +52,8 @@ OAUTH_TOKEN_URL = "https://app.sendmux.ai/mcp/oauth/token" OAUTH_PROTECTED_RESOURCE_METADATA_URL = "https://a2a.sendmux.ai/.well-known/oauth-protected-resource" A2A_PATH_PREFIX = "/a2a/v1" +A2A_PROTOCOL_VERSION = "1.0" +A2A_HSTS_HEADER = "max-age=63072000; includeSubDomains" A2A_RESPONSE_HEADERS = { "cache-control", "content-type", @@ -143,6 +146,15 @@ async def verify_token(self, token: str) -> AccessToken | None: ... class A2AServerCallContextBuilder(DefaultServerCallContextBuilder): def build(self, request: Request) -> ServerCallContext: + requested_version = request.headers.get("A2A-Version") or "0.3" + version_match = re.fullmatch(r"(\d+)\.(\d+)(?:\.[0-9A-Za-z.-]+)?", requested_version) + if version_match is None or ".".join(version_match.groups()[:2]) != A2A_PROTOCOL_VERSION: + raise VersionNotSupportedError( + message=( + f"A2A version '{requested_version}' is not supported by this handler. " + f"Expected version '{A2A_PROTOCOL_VERSION}'." + ) + ) context = super().build(request) access_token = request.scope.get("a2a_access_token") if access_token is not None: @@ -230,7 +242,7 @@ def build_a2a_http_components( route for route in rest_routes if isinstance(route, Route) and route.path == f"{A2A_PATH_PREFIX}/message:send" ) routes: list[BaseRoute] = [ - *create_agent_card_routes(card), + *cacheable_agent_card_routes(card), Route( "/.well-known/oauth-protected-resource", endpoint=a2a_protected_resource_metadata, @@ -241,6 +253,27 @@ def build_a2a_http_components( return A2AHttpComponents(routes=routes, request_handler=request_handler, proxy=proxy) +def cacheable_agent_card_routes(card: AgentCard) -> list[BaseRoute]: + card_route = create_agent_card_routes(card)[0] + card_endpoint = card_route.endpoint + etag = f'"{card.version}"' + cache_headers = { + "Cache-Control": "public, max-age=300", + "ETag": etag, + "Access-Control-Allow-Origin": "*", + "Strict-Transport-Security": A2A_HSTS_HEADER, + } + + async def endpoint(request: Request) -> Response: + if etag in {value.strip() for value in request.headers.get("if-none-match", "").split(",")}: + return Response(status_code=304, headers=cache_headers) + response = await card_endpoint(request) + response.headers.update(cache_headers) + return response + + return [Route(card_route.path, endpoint=endpoint, methods=["GET"])] + + def protected_a2a_route(route: Route, token_verifier: A2ATokenVerifier) -> Route: async def endpoint(request: Request) -> Response: authorization = request.headers.get("authorization", "") @@ -251,11 +284,16 @@ async def endpoint(request: Request) -> Response: {"error": "invalid_token"}, status_code=401, headers={ - "WWW-Authenticate": f'Bearer resource_metadata="{OAUTH_PROTECTED_RESOURCE_METADATA_URL}"' + "Content-Type": "application/a2a+json", + "WWW-Authenticate": f'Bearer resource_metadata="{OAUTH_PROTECTED_RESOURCE_METADATA_URL}"', + "Strict-Transport-Security": A2A_HSTS_HEADER, }, ) request.scope["a2a_access_token"] = access_token - return await route.endpoint(request) + response = await route.endpoint(request) + response.headers["Content-Type"] = "application/a2a+json" + response.headers["Strict-Transport-Security"] = A2A_HSTS_HEADER + return response return Route(route.path, endpoint=endpoint, methods=["POST"]) @@ -269,7 +307,11 @@ async def a2a_protected_resource_metadata(_request: Request) -> JSONResponse: "bearer_methods_supported": ["header"], "resource_name": "Sendmux A2A", }, - headers={"Cache-Control": "public, max-age=300", "Access-Control-Allow-Origin": "*"}, + headers={ + "Cache-Control": "public, max-age=300", + "Access-Control-Allow-Origin": "*", + "Strict-Transport-Security": A2A_HSTS_HEADER, + }, ) @@ -347,7 +389,7 @@ def build_a2a_agent_card() -> AgentCard: AgentInterface( url=A2A_RESOURCE_URL, protocol_binding="HTTP+JSON", - protocol_version="1.0", + protocol_version=A2A_PROTOCOL_VERSION, ) ], capabilities=AgentCapabilities( diff --git a/packages/python/mcp/tests/test_a2a_server.py b/packages/python/mcp/tests/test_a2a_server.py index e9322400..33f3ccaa 100644 --- a/packages/python/mcp/tests/test_a2a_server.py +++ b/packages/python/mcp/tests/test_a2a_server.py @@ -2,6 +2,8 @@ import asyncio import json +import re +from pathlib import Path import httpx from a2a.client import ClientConfig, ClientFactory @@ -31,9 +33,13 @@ def test_agent_card_advertises_a2a_1_0_http_json_and_oauth() -> None: card = agent_card_to_dict(build_a2a_agent_card()) + pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text() + project = pyproject.split("[project]", maxsplit=1)[1].split("\n[", maxsplit=1)[0] + package_version = re.search(r'^version = "([^"]+)"$', project, re.MULTILINE) assert card["name"] == "Sendmux A2A" - assert card["version"] == "1.5.1" + assert package_version is not None + assert card["version"] == package_version.group(1) assert card["description"] assert card["supportedInterfaces"] == [ { @@ -182,10 +188,21 @@ async def proxy_handler(request: httpx.Request) -> httpx.Response: ) as http_client: card_response = await http_client.get("/.well-known/agent-card.json") assert card_response.status_code == 200 + assert "max-age=" in card_response.headers["cache-control"] + assert card_response.headers["etag"] + assert card_response.headers["strict-transport-security"].startswith("max-age=") + + cached_card_response = await http_client.get( + "/.well-known/agent-card.json", + headers={"If-None-Match": card_response.headers["etag"]}, + ) + assert cached_card_response.status_code == 304 + assert cached_card_response.content == b"" resource_response = await http_client.get("/.well-known/oauth-protected-resource") assert resource_response.status_code == 200 assert resource_response.json()["resource"] == A2A_RESOURCE_URL + assert resource_response.headers["strict-transport-security"].startswith("max-age=") missing_auth = await http_client.post( "/a2a/v1/message:send", @@ -193,7 +210,26 @@ async def proxy_handler(request: httpx.Request) -> httpx.Response: json={}, ) assert missing_auth.status_code == 401 + assert missing_auth.headers["content-type"] == "application/a2a+json" assert missing_auth.headers["www-authenticate"].startswith("Bearer resource_metadata=") + assert missing_auth.headers["strict-transport-security"].startswith("max-age=") + + unsupported_version = await http_client.post( + "/a2a/v1/message:send", + content=b"{}", + headers={"A2A-Version": "0.3", "Content-Type": "application/a2a+json"}, + ) + assert unsupported_version.status_code == 400 + assert unsupported_version.headers["content-type"] == "application/a2a+json" + assert unsupported_version.json()["error"]["details"][0]["reason"] == "VERSION_NOT_SUPPORTED" + + unsupported_minor_version = await http_client.post( + "/a2a/v1/message:send", + content=b"{}", + headers={"A2A-Version": "1.1", "Content-Type": "application/a2a+json"}, + ) + assert unsupported_minor_version.status_code == 400 + assert unsupported_minor_version.json()["error"]["details"][0]["reason"] == "VERSION_NOT_SUPPORTED" assert (await http_client.post("/a2a/v1/message:stream", json={})).status_code == 404 assert (await http_client.get("/a2a/v1/tasks/task_123")).status_code == 404 diff --git a/scripts/mcp-registry-version.mjs b/scripts/mcp-registry-version.mjs new file mode 100644 index 00000000..2566ca0a --- /dev/null +++ b/scripts/mcp-registry-version.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; + +const defaultRegistryBaseUrl = "https://registry.modelcontextprotocol.io"; + +export async function checkMcpRegistryVersion({ + fetchImpl = fetch, + name, + registryBaseUrl = defaultRegistryBaseUrl, + version, +}) { + const endpoint = new URL( + `/v0.1/servers/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`, + registryBaseUrl, + ); + const response = await fetchImpl(endpoint, { + headers: { accept: "application/json" }, + }); + + if (response.status === 404) { + return null; + } + if (!response.ok) { + const error = new Error(`MCP Registry returned HTTP ${response.status} for ${name} ${version}`); + error.retryable = response.status === 429 || response.status >= 500; + throw error; + } + + const data = await response.json(); + const actualName = data?.server?.name; + const actualVersion = data?.server?.version; + if (actualName !== name || actualVersion !== version) { + throw new Error( + `MCP Registry returned ${actualName ?? ""} ${actualVersion ?? ""}, expected ${name} ${version}`, + ); + } + return data; +} + +export async function waitForMcpRegistryVersion({ + attempts = 12, + delayMs = 10_000, + fetchImpl = fetch, + name, + registryBaseUrl = defaultRegistryBaseUrl, + retryNotFound = true, + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + version, +}) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const data = await checkMcpRegistryVersion({ fetchImpl, name, registryBaseUrl, version }); + if (data) { + return data; + } + if (!retryNotFound) { + return null; + } + } catch (error) { + if (!error.retryable || attempt === attempts) { + throw error; + } + } + + if (attempt < attempts) { + process.stderr.write( + `MCP Registry has not exposed ${name} ${version}; retrying in ${delayMs}ms.\n`, + ); + await sleep(delayMs); + } + } + + throw new Error(`MCP Registry did not expose ${name} ${version} after ${attempts} attempts`); +} + +async function main() { + const name = requiredEnvironmentVariable("MCP_SERVER_NAME"); + const version = requiredEnvironmentVariable("MCP_SERVER_VERSION"); + + if (process.argv.includes("--check")) { + const data = await waitForMcpRegistryVersion({ name, retryNotFound: false, version }); + process.stdout.write(`${data ? "true" : "false"}\n`); + return; + } + + await waitForMcpRegistryVersion({ name, version }); + process.stdout.write(`Verified ${name} ${version}\n`); +} + +function requiredEnvironmentVariable(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/mcp-registry-version.test.mjs b/scripts/mcp-registry-version.test.mjs new file mode 100644 index 00000000..ead1c716 --- /dev/null +++ b/scripts/mcp-registry-version.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { test } from "node:test"; +import { waitForMcpRegistryVersion } from "./mcp-registry-version.mjs"; + +const name = "io.github.Sendmux/sendmux-mcp"; +const version = "1.6.0"; + +test("waits for the exact MCP Registry version detail endpoint", async (t) => { + const requests = []; + const server = createServer((request, response) => { + requests.push(request.url); + if (requests.length === 1) { + response.writeHead(404).end(); + return; + } + + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ server: { name, version } })); + }); + t.after(() => server.close()); + + const registryBaseUrl = await listen(server); + const result = await waitForMcpRegistryVersion({ + attempts: 2, + delayMs: 0, + name, + registryBaseUrl, + version, + }); + + assert.equal(result.server.name, name); + assert.equal(result.server.version, version); + assert.deepEqual(requests, [ + "/v0.1/servers/io.github.Sendmux%2Fsendmux-mcp/versions/1.6.0", + "/v0.1/servers/io.github.Sendmux%2Fsendmux-mcp/versions/1.6.0", + ]); +}); + +test("rejects mismatched metadata from the exact version endpoint", async (t) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ server: { name, version: "1.5.1" } })); + }); + t.after(() => server.close()); + + const registryBaseUrl = await listen(server); + await assert.rejects( + waitForMcpRegistryVersion({ attempts: 1, name, registryBaseUrl, version }), + /returned io\.github\.Sendmux\/sendmux-mcp 1\.5\.1, expected io\.github\.Sendmux\/sendmux-mcp 1\.6\.0/, + ); +}); + +test("preflight retries transient errors but treats an exact 404 as unpublished", async (t) => { + const requests = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.writeHead(requests.length === 1 ? 503 : 404).end(); + }); + t.after(() => server.close()); + + const registryBaseUrl = await listen(server); + const result = await waitForMcpRegistryVersion({ + attempts: 2, + delayMs: 0, + name, + registryBaseUrl, + retryNotFound: false, + version, + }); + + assert.equal(result, null); + assert.equal(requests.length, 2); +}); + +async function listen(server) { + server.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + const address = server.address(); + return `http://127.0.0.1:${address.port}`; +}