MCP: mcp 2.3 (MCPServer, transport arguments on streamable_http_app, bearer read in middleware); tests for the 2026-07-28 revision and the resource check
Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
This commit is contained in:
@@ -20,7 +20,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.types import TextContent
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from fluksio.ai import client
|
||||
from fluksio.ai.config import AIConfig
|
||||
@@ -68,7 +68,7 @@ async def tool_specs() -> list[dict[str, Any]]:
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description or "",
|
||||
"parameters": tool.inputSchema,
|
||||
"parameters": tool.input_schema,
|
||||
},
|
||||
}
|
||||
for tool in await mcp.list_tools()
|
||||
@@ -108,10 +108,9 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> str:
|
||||
result = await mcp.call_tool(name, arguments)
|
||||
except Exception as exc: # noqa: BLE001 - an unknown tool, or refused arguments
|
||||
return json.dumps({"error": str(exc)})
|
||||
if isinstance(result, dict):
|
||||
text = json.dumps(result)
|
||||
else:
|
||||
text = "".join(c.text for c in result if isinstance(c, TextContent))
|
||||
if not isinstance(result, CallToolResult):
|
||||
return json.dumps({"error": "The tool asked for more input than a chat gives."})
|
||||
text = "".join(c.text for c in result.content if isinstance(c, TextContent))
|
||||
if len(text) > _RESULT_LIMIT:
|
||||
text = text[:_RESULT_LIMIT] + "… (truncated)"
|
||||
return text
|
||||
|
||||
@@ -167,18 +167,21 @@ class _ThroughThePortal:
|
||||
|
||||
def build_http_app(app: object) -> ASGIApp:
|
||||
"""The MCP sub-app, wired to this app's API and its OAuth server."""
|
||||
server.mcp.settings.stateless_http = True
|
||||
server.mcp.settings.json_response = True
|
||||
server.mcp.settings.auth = AuthSettings(
|
||||
issuer_url=AnyHttpUrl(settings.oauth_issuer),
|
||||
resource_server_url=AnyHttpUrl(settings.mcp_resource),
|
||||
required_scopes=None,
|
||||
validate_token_resource=True,
|
||||
)
|
||||
server.mcp.settings.transport_security = _transport_security()
|
||||
# The SDK only takes a verifier through its constructor, and the instance is
|
||||
# module-level so the tools can be declared at import time.
|
||||
server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001
|
||||
|
||||
server.connect(app)
|
||||
return _ThroughThePortal(server.mcp.streamable_http_app())
|
||||
return _ThroughThePortal(
|
||||
server.mcp.streamable_http_app(
|
||||
stateless_http=True,
|
||||
json_response=True,
|
||||
transport_security=_transport_security(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -13,24 +13,46 @@ identity rather than some service account.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.lowlevel.server import request_ctx
|
||||
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
from fluksio.sdk.client import seg as _seg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# FastMCP's own settings model leaves `lifespan` a forward reference, which
|
||||
# pydantic-settings warns about on every start. Nothing reads that field from
|
||||
# the environment, so the warning says nothing about this app.
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message="Field 'lifespan' has an incomplete")
|
||||
mcp = FastMCP("fluksio")
|
||||
#: The ``Authorization`` value tool calls carry onto the REST API. The endpoint's
|
||||
#: middleware sets it from each MCP request; the assistant sets it to the token of
|
||||
#: the person chatting, for the length of their turn, since no MCP request is
|
||||
#: involved there.
|
||||
caller_authorization: ContextVar[str | None] = ContextVar(
|
||||
"caller_authorization", default=None
|
||||
)
|
||||
|
||||
|
||||
async def _carry_authorization(
|
||||
ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
"""Make the bearer token of an MCP request the caller's for its tool calls.
|
||||
|
||||
The transport attaches the originating HTTP request to every JSON-RPC
|
||||
message, so the token is read per message rather than per connection.
|
||||
"""
|
||||
headers = getattr(ctx.request, "headers", None)
|
||||
authorization = headers.get("authorization") if headers else None
|
||||
if not authorization:
|
||||
return await call_next(ctx)
|
||||
token = caller_authorization.set(authorization)
|
||||
try:
|
||||
return await call_next(ctx)
|
||||
finally:
|
||||
caller_authorization.reset(token)
|
||||
|
||||
|
||||
mcp = MCPServer("fluksio", middleware=[_carry_authorization])
|
||||
|
||||
#: The tool calls never leave the process, so the host is a label, not a route.
|
||||
_INTERNAL_BASE = "http://fluksio-mcp.internal"
|
||||
@@ -39,13 +61,6 @@ _INTERNAL_BASE = "http://fluksio-mcp.internal"
|
||||
#: hop. Built once by :func:`connect`, for the endpoint and the assistant alike.
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
#: The ``Authorization`` value for tool calls made from inside this process,
|
||||
#: where no MCP request carries one: the assistant sets it to the token of the
|
||||
#: person chatting, for the length of their turn.
|
||||
caller_authorization: ContextVar[str | None] = ContextVar(
|
||||
"caller_authorization", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_client(client: httpx.AsyncClient | None) -> None:
|
||||
global _client
|
||||
@@ -77,18 +92,10 @@ async def aclose() -> None:
|
||||
async def forward_caller_auth(request: httpx.Request) -> None:
|
||||
"""Carry the caller's bearer token onto the REST call.
|
||||
|
||||
Over the endpoint, the transport attaches the originating HTTP request to
|
||||
every JSON-RPC message, and a tool handler runs inside that request's
|
||||
context, so the token is read per message rather than per connection. A
|
||||
call from inside the process has no such request and uses what
|
||||
:data:`caller_authorization` holds.
|
||||
A tool handler runs inside the context :data:`caller_authorization` was set
|
||||
in, by the endpoint's middleware or by the assistant.
|
||||
"""
|
||||
authorization = caller_authorization.get()
|
||||
try:
|
||||
source = request_ctx.get().request
|
||||
except LookupError:
|
||||
source = None
|
||||
authorization = getattr(source, "headers", {}).get("authorization") or authorization
|
||||
if authorization:
|
||||
request.headers["authorization"] = authorization
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ dependencies = [
|
||||
# The `inject` node's cron. Imported where it is used, behind a message
|
||||
# that cannot fire while this line is here.
|
||||
"croniter>=1.3.0",
|
||||
"mcp>=1.30,<2",
|
||||
"mcp>=2.3,<3",
|
||||
"fluksio-worker>=0.1,<0.2",
|
||||
# The Modules screen installs node code's packages with it, into a venv of
|
||||
# the user's own. Present in the image; a pip install would otherwise have
|
||||
|
||||
@@ -51,9 +51,6 @@ def over_mcp(monkeypatch: pytest.MonkeyPatch):
|
||||
from fluksio.mcp import http as mcp_http
|
||||
from fluksio.mcp import server as mcp_server
|
||||
|
||||
# A FastMCP instance enters its session manager once, and another test in
|
||||
# the run may have built one already.
|
||||
monkeypatch.setattr(mcp_server.mcp, "_session_manager", None, raising=False)
|
||||
mcp_app = mcp_http.build_http_app(app)
|
||||
|
||||
def run(block: Callable[[httpx.AsyncClient], Awaitable[Any]]) -> Any:
|
||||
@@ -194,6 +191,34 @@ def test_an_agent_can_list_and_call_tools(
|
||||
assert called.json()["result"]["isError"] is False
|
||||
|
||||
|
||||
def test_a_token_for_another_resource_is_refused(
|
||||
over_mcp,
|
||||
client: TestClient,
|
||||
superuser_token_headers: dict[str, str],
|
||||
agent: uuid.UUID,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The SDK compares the resource the verifier reports with its own URL."""
|
||||
from fluksio.mcp import http as mcp_http
|
||||
|
||||
verify = mcp_http._JWTVerifier.verify_token
|
||||
|
||||
async def for_elsewhere(self: Any, token: str) -> Any:
|
||||
accepted = await verify(self, token)
|
||||
return accepted.model_copy(update={"resource": "https://other.example/mcp"})
|
||||
|
||||
monkeypatch.setattr(mcp_http._JWTVerifier, "verify_token", for_elsewhere)
|
||||
me = client.get(
|
||||
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
|
||||
).json()
|
||||
token = mcp_token(uuid.UUID(me["id"]), agent)
|
||||
|
||||
async def block(http: httpx.AsyncClient) -> httpx.Response:
|
||||
return await http.post("/mcp", **rpc(token, "tools/list"))
|
||||
|
||||
assert over_mcp(block).status_code == 401
|
||||
|
||||
|
||||
def test_revoking_an_agent_stops_the_token_it_already_holds(
|
||||
over_mcp,
|
||||
client: TestClient,
|
||||
@@ -278,7 +303,7 @@ def test_a_real_client_can_connect_and_call_a_tool(
|
||||
) as http_client:
|
||||
async with streamable_http_client(
|
||||
f"{settings.oauth_issuer}/mcp", http_client=http_client
|
||||
) as (read, write, _):
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
initialized = await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
@@ -286,9 +311,13 @@ def test_a_real_client_can_connect_and_call_a_tool(
|
||||
return initialized, tools, called
|
||||
|
||||
initialized, tools, called = over_mcp(block)
|
||||
assert initialized.serverInfo.name == "fluksio"
|
||||
assert initialized.server_info.name == "fluksio"
|
||||
assert {"list_flows", "run_flow"} <= {tool.name for tool in tools.tools}
|
||||
assert called.isError is False
|
||||
assert called.is_error is False
|
||||
# The tool reached the API as the agent's person: refused calls come back
|
||||
# as an error object in the text, not as a failed call.
|
||||
answer = json.loads(called.content[0].text)
|
||||
assert not (isinstance(answer, dict) and "error" in answer), answer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("revision", ["2025-03-26", "2025-06-18", "2025-11-25"])
|
||||
@@ -326,3 +355,36 @@ def test_clients_of_older_protocol_revisions_still_connect(
|
||||
assert initialized.json()["result"]["protocolVersion"] == revision
|
||||
assert listed.status_code == 200
|
||||
assert "list_flows" in {tool["name"] for tool in listed.json()["result"]["tools"]}
|
||||
|
||||
|
||||
def test_a_request_of_the_2026_07_28_revision_is_served(
|
||||
over_mcp,
|
||||
client: TestClient,
|
||||
superuser_token_headers: dict[str, str],
|
||||
agent: uuid.UUID,
|
||||
) -> None:
|
||||
"""Its requests carry their own protocol envelope and no handshake."""
|
||||
me = client.get(
|
||||
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
|
||||
).json()
|
||||
token = mcp_token(uuid.UUID(me["id"]), agent)
|
||||
meta = {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
}
|
||||
call = rpc(token, "tools/call", _meta=meta, name="list_flows", arguments={})
|
||||
call["headers"] |= {
|
||||
"MCP-Protocol-Version": "2026-07-28",
|
||||
"Mcp-Method": "tools/call",
|
||||
"Mcp-Name": "list_flows",
|
||||
}
|
||||
|
||||
async def block(http: httpx.AsyncClient) -> httpx.Response:
|
||||
return await http.post("/mcp", **call)
|
||||
|
||||
answer = over_mcp(block)
|
||||
assert answer.status_code == 200
|
||||
result = answer.json()["result"]
|
||||
assert result["isError"] is False
|
||||
# Carrying the agent's token on to the API, or the call is refused there.
|
||||
assert "error" not in json.loads(result["content"][0]["text"])
|
||||
|
||||
@@ -39,3 +39,36 @@ def test_a_name_cannot_climb_out_of_its_route() -> None:
|
||||
"DELETE /api/v1/flows/%2E%2E",
|
||||
"GET /api/v1/flows/%2E%2E/nodes/%2E%2E%2Fsource/source",
|
||||
]
|
||||
|
||||
|
||||
def test_the_assistant_calls_a_tool_as_the_person_chatting() -> None:
|
||||
"""The in-process path: no MCP request, so the person's token is set by hand."""
|
||||
from fluksio.ai import agent
|
||||
|
||||
seen: list[str | None] = []
|
||||
|
||||
def record(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.headers.get("authorization"))
|
||||
return httpx.Response(200, json=[{"name": "home"}])
|
||||
|
||||
async def calls() -> tuple[list[dict], str]:
|
||||
previous = server._client
|
||||
mock = httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(record),
|
||||
base_url="http://test",
|
||||
event_hooks={"request": [server.forward_caller_auth]},
|
||||
)
|
||||
server.set_client(mock)
|
||||
try:
|
||||
specs = await agent.tool_specs()
|
||||
answer = await agent.mcp_caller("Bearer person")("list_flows", {})
|
||||
finally:
|
||||
server.set_client(previous)
|
||||
await mock.aclose()
|
||||
return specs, answer
|
||||
|
||||
specs, answer = asyncio.run(calls())
|
||||
assert {"list_flows", "run_flow"} <= {s["function"]["name"] for s in specs}
|
||||
assert all(s["function"]["parameters"]["type"] == "object" for s in specs)
|
||||
assert seen == ["Bearer person"]
|
||||
assert "home" in answer
|
||||
|
||||
Reference in New Issue
Block a user