Skip to content
Merged
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
106 changes: 4 additions & 102 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 47 additions & 5 deletions packages/python/mcp/sendmux_mcp/a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
SecurityRequirement,
SecurityScheme,
StringList,
VersionNotSupportedError,
)
from fastmcp.server.auth import AccessToken
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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", "")
Expand All @@ -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"])

Expand All @@ -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,
},
)


Expand Down Expand Up @@ -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(
Expand Down
38 changes: 37 additions & 1 deletion packages/python/mcp/tests/test_a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import asyncio
import json
import re
from pathlib import Path

import httpx
from a2a.client import ClientConfig, ClientFactory
Expand Down Expand Up @@ -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"] == [
{
Expand Down Expand Up @@ -182,18 +188,48 @@ 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",
headers={"Authorization": ""},
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
Expand Down
Loading
Loading