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
13 changes: 11 additions & 2 deletions docs/adr/0002-agentgateway-mcp-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,20 @@ The ADK provides a first-class AgentGateway MCP router with these boundaries:
- trusted descriptions, required scopes, and approval policy come from ADK configuration,
never from an MCP server's untrusted annotations;
- credentials are minted per discovery or tool call and narrowed to the run, route, and
tool; session leases remain keyed by server, tenant, and subject;
tool; authorization leases remain keyed by server, tenant, and subject and are not MCP
transport sessions;
- discovery is pinned into one immutable tool set for a run, with a default ceiling of 40;
- arguments and responses are bounded, gateway responses remain untrusted tool results,
and an MCP error becomes a typed ADK failure;
- the optional `mcp` extra owns the Streamable HTTP transport. The router itself speaks a
small protocol so tests and alternate transports do not import vendor types.

As of MCP `2026-07-28`, every AgentGateway operation first uses the official SDK's
`server/discover` path and then sends self-contained requests. It does not call legacy
`initialize`, does not retain `Mcp-Session-Id`, and may reach a different healthy backend
replica on the next operation. The in-process `McpSession.initialize()` abstraction remains
for transport-neutral and legacy compatibility tests; it is not the production HTTP path.

When AgentGateway is down, discovery and calls fail closed with a typed error. There is no
direct-to-backend fallback because that would bypass the single-egress policy. When the
registry is down, already-rendered AgentGateway routes and already-pinned run tool sets keep
Expand All @@ -57,4 +64,6 @@ bounded MCP session per operation initially; pooling can be added behind the tra
protocol without changing the public router or weakening tenant leases.

The change is additive. Rollback means removing the router from consumer composition; the
existing MCP authorization API and direct transports continue to work unchanged.
existing MCP authorization API and direct transports continue to work unchanged. Rolling
the transport back to handshake-era MCP is not an approved production rollback because it
would restore session affinity and pod-loss coupling.
5 changes: 5 additions & 0 deletions docs/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ The client speaks to an `McpSession`, which is a protocol: `initialize`, `list_t
`call_tool`, `close`. Transports implement it, tests implement it in process, and
adopting a server therefore says nothing about how that server is reached.

The production `McpStreamableHttpTransport` uses the official SDK's stateless
`server/discover` path for MCP `2026-07-28`; it never performs the legacy initialization
handshake or depends on an MCP session ID. The `McpSession.initialize()` member above is a
transport-neutral compatibility abstraction, not the production HTTP wire sequence.

## Declare a server

Install the optional integration in a source checkout first:
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1758,7 +1758,7 @@ class McpSession(Protocol) {call_tool(self, name: 'str', arguments: 'Mapping[str

**Stability:** `alpha`

Open one bounded official-SDK MCP session for each gateway operation.
Open one bounded official-SDK MCP connection for each gateway operation.

```python
class McpStreamableHttpTransport(object) {call_tool(self, *, endpoint: 'str', tool: 'str', arguments: 'Mapping[str, JsonValue]', headers: 'Mapping[str, str]', meta: 'Mapping[str, str]', timeout_seconds: 'float', max_result_bytes: 'int') -> 'GatewayToolResult'; list_tools(self, *, endpoint: 'str', headers: 'Mapping[str, str]', meta: 'Mapping[str, str]', timeout_seconds: 'float', max_result_bytes: 'int', max_tools: 'int') -> 'tuple[McpToolDescriptor, ...]'}
Expand Down Expand Up @@ -3368,7 +3368,7 @@ def agent_gateway_tools(routed: 'AgentGatewayTools') -> 'tuple[Tool[..., JsonVal

**Stability:** `alpha`

Open one bounded official-SDK MCP session for each gateway operation.
Open one bounded official-SDK MCP connection for each gateway operation.

```python
class McpStreamableHttpTransport(object) {call_tool(self, *, endpoint: 'str', tool: 'str', arguments: 'Mapping[str, JsonValue]', headers: 'Mapping[str, str]', meta: 'Mapping[str, str]', timeout_seconds: 'float', max_result_bytes: 'int') -> 'GatewayToolResult'; list_tools(self, *, endpoint: 'str', headers: 'Mapping[str, str]', meta: 'Mapping[str, str]', timeout_seconds: 'float', max_result_bytes: 'int', max_tools: 'int') -> 'tuple[McpToolDescriptor, ...]'}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ tesserix-adk = "tesserix_adk.cli.__main__:main"
[project.optional-dependencies]
a2a = ["a2a-sdk[http-server]>=1.1.2"]
google-adk = ["google-adk[a2a]>=2.8,<3"]
mcp = ["mcp>=1.9", "jsonschema>=4.23"]
mcp = ["mcp>=2.1.1,<3", "jsonschema>=4.23"]
temporal = ["temporalio>=1.9"]
# Recorded compatibility caps are justified in security/dependencies.toml.
graphiti = ["graphiti-core>=0.14,<1"]
Expand Down
22 changes: 20 additions & 2 deletions security/dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ compatible releases installable and lets CI reveal an upstream break.

[[floor]]
package = "mcp"
floor = "1.9"
reason = "Oldest release with the 1.x server and client surface the mcp extra targets."
floor = "2.1.1"
reason = """
First official SDK release tested with MCP 2026-07-28 stateless `server/discover`.
Earlier releases either use the initialization handshake or predate the final stateless
wire contract required by the AgentGateway transport.
"""

[[floor]]
package = "jsonschema"
Expand Down Expand Up @@ -120,3 +124,17 @@ Raise or remove this cap after the google-adk extras leg passes against 3.x and
interop changes are reviewed and documented.
"""
owner = "@sam123ben"

[[cap]]
package = "mcp"
cap = "3"
incompatibility = """
The AgentGateway adapter targets the official MCP SDK 2.x `ClientSession.discover`,
Streamable HTTP context-manager, pagination, and tool-result contracts. A 3.x major may
change those boundaries and cannot be claimed until the transport suite passes against it.
"""
trigger = """
Raise or remove this cap after the MCP extra and AgentGateway transport suites pass against
3.x and its stateless protocol behavior has been reviewed.
"""
owner = "@sam123ben"
14 changes: 7 additions & 7 deletions src/tesserix_adk/adapters/agentgateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def model_dump(self, *, mode: str, by_alias: bool) -> dict[str, Any]:


class _ClientSession(Protocol):
async def initialize(self) -> object:
"""Negotiate the MCP session."""
async def discover(self) -> object:
"""Discover the modern stateless MCP surface."""

async def list_tools(self, cursor: str | None = None) -> _Dumpable:
"""Return one page of tools."""
Expand All @@ -63,7 +63,7 @@ async def call_tool(


class McpStreamableHttpTransport:
"""Open one bounded official-SDK MCP session for each gateway operation."""
"""Open one bounded official-SDK MCP connection for each gateway operation."""

async def list_tools(
self,
Expand All @@ -75,7 +75,7 @@ async def list_tools(
max_result_bytes: int,
max_tools: int,
) -> tuple[McpToolDescriptor, ...]:
"""Initialize a routed server and return all of its MCP tool pages."""
"""Discover a routed server and return all of its MCP tool pages."""
del meta
async with asyncio.timeout(timeout_seconds):
async with self._session(
Expand Down Expand Up @@ -138,7 +138,7 @@ async def call_tool(
timeout_seconds: float,
max_result_bytes: int,
) -> GatewayToolResult:
"""Initialize a routed server and call one tool through the official SDK."""
"""Discover a routed server and call one tool through the official SDK."""
async with asyncio.timeout(timeout_seconds):
async with self._session(
endpoint=endpoint, headers=headers, timeout_seconds=timeout_seconds
Expand All @@ -157,7 +157,7 @@ async def call_tool(
async def _session(
self, *, endpoint: str, headers: Mapping[str, str], timeout_seconds: float
) -> AsyncIterator[_ClientSession]:
"""Open and initialize one SDK session without ambient proxies or redirects."""
"""Open one stateless SDK connection without ambient proxies or redirects."""
mcp = require_extra("mcp", "mcp")
streamable_module = require_extra("mcp", "mcp.client.streamable_http")
session_factory = cast("_SessionFactory", mcp.ClientSession)
Expand All @@ -175,7 +175,7 @@ async def _session(
read, write, read_timeout_seconds=timedelta(seconds=timeout_seconds)
) as session,
):
await session.initialize()
await session.discover()
yield session


Expand Down
12 changes: 6 additions & 6 deletions tests/test_agentgateway_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,19 @@ class _Session:

def __init__(self, read: object, write: object, **kwargs: object) -> None:
del read, write, kwargs
self.initialized = False
self.discovered = False

async def __aenter__(self) -> _Session:
return self

async def __aexit__(self, *args: object) -> None:
del args

async def initialize(self) -> None:
self.initialized = True
async def discover(self) -> None:
self.discovered = True

async def list_tools(self, cursor: str | None = None) -> object:
assert self.initialized
assert self.discovered
return _Dumped(self.pages[cursor])

async def call_tool(
Expand All @@ -47,7 +47,7 @@ async def call_tool(
*,
meta: dict[str, object] | None = None,
) -> object:
assert self.initialized
assert self.discovered
self.calls.append((name, arguments, read_timeout_seconds, meta))
return _Dumped(self.result)

Expand Down Expand Up @@ -103,7 +103,7 @@ def _install_fake_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(sys.modules, "mcp.client.streamable_http", streamable)


async def test_streamable_http_transport_initializes_and_removes_vendor_types(
async def test_streamable_http_transport_discovers_without_legacy_initialization(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_mcp(monkeypatch)
Expand Down
16 changes: 8 additions & 8 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading