From 6b9b8dc96d90f51c98ac732b44bd6a53ed838b34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:46:57 +0000 Subject: [PATCH 1/2] chore(deps): Update mcp requirement from <2,>=1.28 to >=1.28,<3 Updates the requirements on [mcp](https://github.com/modelcontextprotocol/python-sdk) to permit the latest version. - [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases) - [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md) - [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v1.28.0...v2.1.1) --- updated-dependencies: - dependency-name: mcp dependency-version: 2.1.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 79d8648..2983987 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ # older version that still satisfies Flow's transitive requirement. "cryptography>=50.0.0", # Official Model Context Protocol SDK (stdio server transport). - "mcp>=1.28,<2", + "mcp>=1.28,<3", # Structured concurrency runtime the mcp SDK already uses; we call # anyio.run / to_thread directly. "anyio>=4.0", From 67e280af3f7e8d9e09f411583cd9fb61cc35cb78 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 10 Sep 2026 16:39:20 -0400 Subject: [PATCH 2/2] chore(deps): support MCP 2 server and preserve SDK 1 --- .github/workflows/ci.yml | 7 +- docs/DISTRIBUTION.md | 7 + pyproject.toml | 2 + src/openadapt_agent/mcp.py | 72 +++++-- tests/conftest.py | 26 +++ tests/test_authoring.py | 27 +-- tests/test_mcp_server.py | 427 +++++++++++++++++++++---------------- uv.lock | 115 +++++++--- 8 files changed, 436 insertions(+), 247 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fd5c88..6901e93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,11 @@ jobs: fail-fast: false matrix: include: - # Cover both dependency edges without doubling the hosted matrix. + # Cover SDK 1, SDK 2's floor, and current dependencies in three jobs. - python-version: "3.10" dependency-set: floor - python-version: "3.11" - dependency-set: current + dependency-set: mcp2-floor - python-version: "3.12" dependency-set: current steps: @@ -36,6 +36,9 @@ jobs: pip install -e ".[dev]" "openadapt-flow==1.26.0" "mcp==1.28.0" + - name: Install MCP 2 floor + if: matrix.dependency-set == 'mcp2-floor' + run: pip install -e ".[dev]" "mcp==2.0.0" - name: Install current allowed dependencies if: matrix.dependency-set == 'current' run: pip install -e ".[dev]" diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 709f538..e143e43 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -49,6 +49,13 @@ Continue and Skip available. Clients without MCP form elicitation use Flow's attended console/CLI, where all five capabilities remain available. This matches the security model in [`DESIGN.md`](DESIGN.md). +The server supports MCP Python SDK 1.28 through 2.x. Attended tools require +form elicitation over the `initialize` handshake. With the SDK 2 Python +`Client`, set `mode="legacy"` for this connection. Its default discovery path +negotiates protocol 2026-07-28, which can't carry server-initiated confirmation +requests. Tool discovery still works on that protocol; attended decisions +refuse before submission. See the SDK's [client migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/v2.2.0/docs/migration.md#client-defaults-to-modeauto). + > There is intentionally **no** hosted, multi-tenant "official OpenAdapt > workflow server" that exposes OpenAdapt-operated workflows to the > public. v2 is stdio-only, single-user, local. A hosted control plane is diff --git a/pyproject.toml b/pyproject.toml index 2983987..e15595c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ dependencies = [ "cryptography>=50.0.0", # Official Model Context Protocol SDK (stdio server transport). "mcp>=1.28,<3", + # MCP 2 low-level handlers require explicit input-schema validation. + "jsonschema>=4.20.0", # Structured concurrency runtime the mcp SDK already uses; we call # anyio.run / to_thread directly. "anyio>=4.0", diff --git a/src/openadapt_agent/mcp.py b/src/openadapt_agent/mcp.py index 1f6c31e..59cae7b 100644 --- a/src/openadapt_agent/mcp.py +++ b/src/openadapt_agent/mcp.py @@ -20,6 +20,7 @@ import anyio import mcp.types as types +from jsonschema import ValidationError, validate from mcp.server.lowlevel import Server from mcp.server.stdio import stdio_server @@ -61,13 +62,16 @@ } -async def _confirm_attended_action(server: Server, name: str) -> None: +async def _confirm_attended_action(context: Any, name: str) -> None: """Require a second, protocol-native human confirmation before mutation.""" - context = server.request_context session = context.session params = session.client_params elicitation = params.capabilities.elicitation if params is not None else None - if elicitation is None or elicitation.form is None: + if ( + elicitation is None + or elicitation.form is None + or not getattr(session, "can_send_request", True) + ): raise BridgeError( "attended actions require an MCP client with form elicitation so " "the local operator can confirm this exact decision; use Flow's " @@ -136,12 +140,13 @@ def build_server( """Wrap workflow and/or authoring bridges in an MCP Server (no I/O started).""" if bridge is None and authoring is None: raise ValueError("MCP server requires a workflow bridge or an authoring bridge") - server: Server = Server( - SERVER_NAME, - instructions=_server_instructions(authoring), - ) - @server.list_tools() + def _tool_specs(): + return ( + *(bridge.list_tool_specs() if bridge is not None else ()), + *(authoring.list_tool_specs() if authoring is not None else ()), + ) + async def _list_tools() -> list[types.Tool]: return [ types.Tool( @@ -155,17 +160,25 @@ async def _list_tools() -> list[types.Tool]: ), **({"_meta": spec.meta} if spec.meta is not None else {}), ) - for spec in ( - *(bridge.list_tool_specs() if bridge is not None else ()), - *(authoring.list_tool_specs() if authoring is not None else ()), - ) + for spec in _tool_specs() ] - @server.call_tool() - async def _call_tool(name: str, arguments: dict[str, Any] | None): + async def _call_tool( + context: Any, name: str, arguments: dict[str, Any] | None + ) -> types.CallToolResult: try: + spec = next((spec for spec in _tool_specs() if spec.name == name), None) + if spec is None: + raise BridgeError("unknown or unavailable tool name") + # MCP 2 removed the low-level decorator's schema validation. + # Keep it explicit on both SDKs, before confirmation or dispatch. + # ValidationError text contains input values, so never return it. + try: + validate(arguments or {}, spec.input_schema) + except ValidationError: + raise BridgeError("tool arguments do not match the input schema") from None if name in ATTENDED_TOOLS: - await _confirm_attended_action(server, name) + await _confirm_attended_action(context, name) def call() -> dict[str, Any]: payload = dict(arguments or {}) @@ -207,8 +220,35 @@ def call() -> dict[str, Any]: ], isError=True, ) - return [types.TextContent(type="text", text=json.dumps(result, indent=2))] + # Both SDKs accept wire aliases in constructors. Always return the + # explicit result type: MCP 2 no longer wraps a bare content list. + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(result, indent=2))] + ) + + if hasattr(Server, "list_tools"): + server = Server(SERVER_NAME, instructions=_server_instructions(authoring)) + server.list_tools()(_list_tools) + async def _call_v1(name: str, arguments: dict[str, Any] | None): + return await _call_tool(server.request_context, name, arguments) + + # The common handler enforces the same schema without reflecting inputs. + server.call_tool(validate_input=False)(_call_v1) + else: + + async def _list_v2(context: Any, params: Any) -> types.ListToolsResult: + return types.ListToolsResult(tools=await _list_tools()) + + async def _call_v2(context: Any, params: types.CallToolRequestParams): + return await _call_tool(context, params.name, params.arguments) + + server = Server( + SERVER_NAME, + instructions=_server_instructions(authoring), + on_list_tools=_list_v2, + on_call_tool=_call_v2, + ) return server diff --git a/tests/conftest.py b/tests/conftest.py index 72a9cc7..9f3bcdb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,3 +126,29 @@ def halt_report() -> dict: "model_calls": 0, "total_ms": 2222.0, } + + +@pytest.fixture() +def mcp_client(): + """Exercise the real SDK session and server over its in-memory transport.""" + from contextlib import asynccontextmanager + + import anyio + from mcp import ClientSession + from mcp.shared.memory import create_client_server_memory_streams + + @asynccontextmanager + async def connect(server, *, modern=False, **kwargs): + with anyio.fail_after(10): + async with create_client_server_memory_streams() as (client, transport): + async with anyio.create_task_group() as tasks: + tasks.start_soon(server.run, *transport, server.create_initialization_options()) + async with ClientSession(*client, **kwargs) as session: + if modern: + await session.discover() + else: + await session.initialize() + yield session + tasks.cancel_scope.cancel() + + return connect diff --git a/tests/test_authoring.py b/tests/test_authoring.py index 378f322..dcba8a5 100644 --- a/tests/test_authoring.py +++ b/tests/test_authoring.py @@ -6,7 +6,6 @@ from pathlib import Path import anyio -import mcp.types as types import pytest from openadapt_agent.authoring import ( @@ -374,16 +373,17 @@ def test_unknown_fields_and_missing_click_target_are_refused(): bridge.dispatch("click", {}) -def test_mcp_lists_authoring_probe_tools_without_run_tools(bundles_root, runner_config): +def test_mcp_lists_authoring_probe_tools_without_run_tools( + bundles_root, runner_config, mcp_client +): from openadapt_agent.bridge import AgentBridge authoring = AuthoringBridge(FakeAuthoringSession()) server = build_server(authoring=authoring) async def list_names(): - handler = server.request_handlers[types.ListToolsRequest] - result = await handler(types.ListToolsRequest(method="tools/list")) - return [tool.name for tool in result.root.tools] + async with mcp_client(server) as client: + return [tool.name for tool in (await client.list_tools()).tools] names = anyio.run(list_names) assert names[:4] == list(AUTHORING_PROBE_TOOLS) @@ -397,9 +397,8 @@ async def list_names(): ) async def combined_names(): - handler = combined.request_handlers[types.ListToolsRequest] - result = await handler(types.ListToolsRequest(method="tools/list")) - return [tool.name for tool in result.root.tools] + async with mcp_client(combined) as client: + return [tool.name for tool in (await client.list_tools()).tools] both = anyio.run(combined_names) assert "list_workflows" in both @@ -407,18 +406,14 @@ async def combined_names(): assert not any(name.startswith("run_") for name in both) -def test_mcp_observe_call_is_projected(): +def test_mcp_observe_call_is_projected(mcp_client): server = build_server(authoring=AuthoringBridge(FakeAuthoringSession())) async def call_observe(): - handler = server.request_handlers[types.CallToolRequest] - return await handler( - types.CallToolRequest( - params=types.CallToolRequestParams(name="observe", arguments={}) - ) - ) + async with mcp_client(server) as client: + return await client.call_tool("observe", {}) - result = anyio.run(call_observe).root + result = anyio.run(call_observe) payload = json.loads(result.content[0].text) assert payload["schema_version"] == "openadapt.authoring.observe/v1" assert "screenshot" not in result.content[0].text diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index a855d81..d714d0f 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,61 +1,74 @@ -"""MCP layer: the low-level Server exposes the bridge's tools over MCP types.""" +"""MCP discovery, invocation, and confirmation through the real SDK transport.""" from __future__ import annotations +import json +import sys import threading -from types import SimpleNamespace import anyio +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client import mcp.types as types import pytest -import openadapt_agent.mcp as mcp_mod +from openadapt_agent.attended import ATTENDED_TOOLS from openadapt_agent.bridge import AgentBridge -from openadapt_agent.bridge import BridgeError -from openadapt_agent.mcp import _confirm_attended_action, build_server +from openadapt_agent.mcp import build_server -def test_server_builds_and_lists_bridge_tools(bundles_root, runner_config): - bridge = AgentBridge(bundles_root, runner_config, allow_run=True) - server = build_server(bridge) +def wire(model): + """Both SDK generations serialize the same protocol aliases.""" + return model.model_dump(by_alias=True, mode="json") + + +def action_arguments(name="continue_attention"): + return { + "attention_id": "0" * 24, + "capability_digest": "sha256:" + "0" * 64, + "idempotency_key": "stable-mcp-test-0001", + ATTENDED_TOOLS[name].confirmation: True, + } + + +def test_server_builds_and_lists_bridge_tools(bundles_root, runner_config, mcp_client): + server = build_server(AgentBridge(bundles_root, runner_config, allow_run=True)) async def list_tools(): - handler = server.request_handlers[types.ListToolsRequest] - result = await handler(types.ListToolsRequest(method="tools/list")) - return result.root.tools + async with mcp_client(server) as client: + return wire(await client.list_tools())["tools"] tools = anyio.run(list_tools) - names = [t.name for t in tools] - assert "list_workflows" in names - assert "get_run_report" in names - assert "list_needs_attention" in names - run_names = [name for name in names if name.startswith("run_workflow_")] - assert len(run_names) == 1 - run_tool = next(t for t in tools if t.name == run_names[0]) - assert run_tool.inputSchema["properties"]["note"]["type"] == "string" - assert run_tool.inputSchema["required"] == ["note"] - assert "default" not in run_tool.inputSchema["properties"]["note"] - assert "governed" in (run_tool.description or "") - assert run_tool.annotations.readOnlyHint is False - assert run_tool.annotations.destructiveHint is True - assert run_tool.meta == {"requires_seal": True} - assert "requires_seal: true" in (run_tool.description or "") - assert "unsigned success" in (run_tool.description or "") - list_tool = next(t for t in tools if t.name == "list_needs_attention") - assert list_tool.annotations.readOnlyHint is True - assert list_tool.meta is None - - -def test_server_read_only_when_run_not_allowed(bundles_root, runner_config): + names = [t["name"] for t in tools] + assert {"list_workflows", "get_run_report", "list_needs_attention"} <= set(names) + run_tools = [t for t in tools if t["name"].startswith("run_workflow_")] + assert len(run_tools) == 1 + run_tool = run_tools[0] + assert run_tool["inputSchema"]["properties"]["note"]["type"] == "string" + assert run_tool["inputSchema"]["required"] == ["note"] + assert "default" not in run_tool["inputSchema"]["properties"]["note"] + assert "governed" in run_tool["description"] + assert run_tool["annotations"]["readOnlyHint"] is False + assert run_tool["annotations"]["destructiveHint"] is True + assert run_tool["_meta"] == {"requires_seal": True} + assert "requires_seal: true" in run_tool["description"] + assert "unsigned success" in run_tool["description"] + list_tool = next(t for t in tools if t["name"] == "list_needs_attention") + assert list_tool["annotations"]["readOnlyHint"] is True + assert list_tool.get("_meta") is None + + +def test_server_read_only_when_run_not_allowed(bundles_root, runner_config, mcp_client): bridge = AgentBridge(bundles_root, runner_config, allow_run=False) server = build_server(bridge) - async def list_tools(): - handler = server.request_handlers[types.ListToolsRequest] - result = await handler(types.ListToolsRequest(method="tools/list")) - return [t.name for t in result.root.tools] + async def probe(): + async with mcp_client(server) as client: + names = [t.name for t in (await client.list_tools()).tools] + result = await client.call_tool("list_workflows", {}) + return names, json.loads(result.content[0].text), wire(result) - names = anyio.run(list_tools) + names, payload, result = anyio.run(probe) assert names == [ "list_workflows", "get_workflow", @@ -63,60 +76,58 @@ async def list_tools(): "list_needs_attention", "get_attention_item", ] + assert result["isError"] is False + assert payload["run_tools_enabled"] is False + assert len(payload["workflows"]) == 1 def test_server_exports_reject_as_a_destructive_local_mutation( bundles_root, runner_config, + mcp_client, ): - bridge = AgentBridge( - bundles_root, - runner_config, - allow_attended_actions=True, + server = build_server( + AgentBridge( + bundles_root, runner_config, allow_attended_actions=True, attended_service=object() + ) ) - server = build_server(bridge) async def reject_tool(): - handler = server.request_handlers[types.ListToolsRequest] - result = await handler(types.ListToolsRequest(method="tools/list")) - return next(tool for tool in result.root.tools if tool.name == "reject_attention") + async with mcp_client(server) as client: + return next( + t + for t in wire(await client.list_tools())["tools"] + if t["name"] == "reject_attention" + ) - annotations = anyio.run(reject_tool).annotations - assert annotations.readOnlyHint is False - assert annotations.destructiveHint is True - assert annotations.idempotentHint is True - assert annotations.openWorldHint is False + annotations = anyio.run(reject_tool)["annotations"] + assert annotations["readOnlyHint"] is False + assert annotations["destructiveHint"] is True + assert annotations["idempotentHint"] is True + assert annotations["openWorldHint"] is False -def test_bridge_refusals_are_mcp_error_results(bundles_root, runner_config): +def test_bridge_refusals_are_mcp_error_results(bundles_root, runner_config, mcp_client): runner_config.runs_dir.mkdir() - bridge = AgentBridge(bundles_root, runner_config) - server = build_server(bridge) + server = build_server(AgentBridge(bundles_root, runner_config)) - async def call_missing_item(): - handler = server.request_handlers[types.CallToolRequest] - return await handler( - types.CallToolRequest( - params=types.CallToolRequestParams( - name="get_attention_item", - arguments={"attention_id": "0" * 24}, - ) - ) - ) + async def call(): + async with mcp_client(server) as client: + return await client.call_tool("get_attention_item", {"attention_id": "0" * 24}) - result = anyio.run(call_missing_item).root - assert result.isError is True - assert "no current attention item" in result.content[0].text + result = wire(anyio.run(call)) + assert result["isError"] is True + assert "no current attention item" in result["content"][0]["text"] def test_unexpected_local_exception_text_never_crosses_mcp( monkeypatch, bundles_root, runner_config, + mcp_client, ): secret = "Jane Roe MRN-9911 sk_live_secret /private/protected/path" bridge = AgentBridge(bundles_root, runner_config) - server = build_server(bridge) def fail(_name, _arguments): raise RuntimeError(secret) @@ -124,137 +135,195 @@ def fail(_name, _arguments): monkeypatch.setattr(bridge, "dispatch", fail) async def call(): - handler = server.request_handlers[types.CallToolRequest] - return await handler( - types.CallToolRequest( - params=types.CallToolRequestParams( - name="list_workflows", - arguments={}, - ) - ) - ) + async with mcp_client(build_server(bridge)) as client: + return await client.call_tool("list_workflows", {}) - result = anyio.run(call).root - assert result.isError is True - assert secret not in result.content[0].text - assert "failed safely" in result.content[0].text + result = wire(anyio.run(call)) + assert result["isError"] is True + assert secret not in json.dumps(result) + assert "failed safely" in result["content"][0]["text"] -def test_blocking_calls_leave_the_mcp_event_loop(monkeypatch, bundles_root, runner_config): +@pytest.mark.parametrize("name", list(ATTENDED_TOOLS)) +@pytest.mark.parametrize("answer", ["accept", "decline", "cancel", "unconfirmed", "unsupported"]) +def test_attended_confirmation_over_real_session( + name, + answer, + monkeypatch, + bundles_root, + runner_config, + mcp_client, +): bridge = AgentBridge( - bundles_root, - runner_config, - allow_attended_actions=True, - attended_service=object(), + bundles_root, runner_config, allow_attended_actions=True, attended_service=object() ) - server = build_server(bridge) - seen: dict[str, int] = {} + calls = [] + prompts = [] - def dispatch(name, _arguments): - seen[name] = threading.get_ident() + def dispatch(tool, arguments): + calls.append((tool, arguments, threading.get_ident())) return {"ok": True} monkeypatch.setattr(bridge, "dispatch", dispatch) - async def confirm(_server, _name): - return None - - monkeypatch.setattr(mcp_mod, "_confirm_attended_action", confirm) + async def elicit(context, params): + prompts.append((context.request_id, wire(params))) + return types.ElicitResult( + action="accept" if answer == "unconfirmed" else answer, + content={"confirmed": answer == "accept"}, + ) - async def call_both(): + async def call(): event_thread = threading.get_ident() - handler = server.request_handlers[types.CallToolRequest] - calls = { - "continue_attention": { - "attention_id": "0" * 24, - "capability_digest": "sha256:" + "0" * 64, - "idempotency_key": "stable-thread-test-0001", - "human_completed": True, - }, - "list_needs_attention": {}, - } - for name, arguments in calls.items(): - await handler( - types.CallToolRequest( - params=types.CallToolRequestParams( - name=name, - arguments=arguments, - ) - ) - ) - return event_thread + kwargs = {} if answer == "unsupported" else {"elicitation_callback": elicit} + async with mcp_client(build_server(bridge), **kwargs) as client: + result = await client.call_tool(name, action_arguments(name)) + if answer == "accept": + await client.call_tool("list_needs_attention", {}) + return event_thread, wire(result) + + event_thread, result = anyio.run(call) + assert result["isError"] is (answer != "accept") + if answer == "accept": + assert [(tool, args) for tool, args, _ in calls] == [ + (name, action_arguments(name)), + ("list_needs_attention", {}), + ] + assert all(thread != event_thread for _, _, thread in calls) + else: + assert calls == [] + if answer == "unsupported": + assert prompts == [] + assert "form elicitation" in result["content"][0]["text"] + else: + assert len(prompts) == 1 + request_id, prompt = prompts[0] + assert request_id is not None + assert prompt["requestedSchema"]["properties"]["confirmed"]["type"] == "boolean" + if name == "reject_attention": + assert "earlier run actions may have effects" in prompt["message"] + if name == "continue_attention": + assert "person must already have completed" in prompt["message"] - event_thread = anyio.run(call_both) - assert seen["continue_attention"] != event_thread - assert seen["list_needs_attention"] != event_thread +@pytest.mark.parametrize( + "name,arguments", + [ + ("continue_attention", {}), + ("continue_attention", {**action_arguments(), "human_completed": False}), + ("continue_attention", {**action_arguments(), "attention_id": "private-input-value"}), + ("continue_attention", {**action_arguments(), "extra": "private-input-value"}), + ("get_workflow", {"workflow": 7}), + ("unknown_tool", {}), + ], +) +def test_invalid_arguments_never_confirm_or_dispatch( + name, + arguments, + monkeypatch, + bundles_root, + runner_config, + mcp_client, +): + bridge = AgentBridge( + bundles_root, runner_config, allow_attended_actions=True, attended_service=object() + ) + calls = [] -class ElicitationSession: - def __init__(self, *, action="accept", confirmed=True, supported=True): - self.client_params = SimpleNamespace( - capabilities=SimpleNamespace( - elicitation=(SimpleNamespace(form=object()) if supported else None) - ) - ) - self.result = SimpleNamespace( - action=action, - content={"confirmed": confirmed}, - ) - self.calls = [] + def dispatch(*args): + calls.append(args) + return {"ok": True} - async def elicit_form(self, message, schema, related_request_id=None): - self.calls.append((message, schema, related_request_id)) - return self.result + async def elicit(*args): + calls.append(args) + return types.ElicitResult(action="accept", content={"confirmed": True}) + monkeypatch.setattr(bridge, "dispatch", dispatch) -class ElicitationServer: - def __init__(self, session): - self.request_context = SimpleNamespace( - session=session, - request_id="request-123", - ) + async def call(): + async with mcp_client(build_server(bridge), elicitation_callback=elicit) as client: + return wire(await client.call_tool(name, arguments)) + result = anyio.run(call) + assert result["isError"] is True + assert calls == [] + assert "private-input-value" not in json.dumps(result) -def test_attended_action_requires_protocol_native_human_confirmation(): - session = ElicitationSession() - anyio.run( - _confirm_attended_action, - ElicitationServer(session), - "continue_attention", - ) - message, schema, request_id = session.calls[0] - assert "person must already have completed" in message - assert schema["properties"]["confirmed"]["type"] == "boolean" - assert request_id == "request-123" - - -def test_reject_requires_protocol_native_terminal_confirmation(): - session = ElicitationSession() - anyio.run( - _confirm_attended_action, - ElicitationServer(session), - "reject_attention", + +@pytest.mark.skipif(not hasattr(ClientSession, "discover"), reason="MCP 2 protocol only") +def test_modern_discovery_preserves_tools_and_refuses_unavailable_confirmation( + monkeypatch, + bundles_root, + runner_config, + mcp_client, +): + bridge = AgentBridge( + bundles_root, runner_config, allow_attended_actions=True, attended_service=object() ) - message, schema, request_id = session.calls[0] - assert "end this run" in message - assert "dispatches no new action" in message - assert "earlier run actions may have effects" in message - assert schema["properties"]["confirmed"]["type"] == "boolean" - assert request_id == "request-123" + calls = [] + monkeypatch.setattr(bridge, "dispatch", lambda *args: calls.append(args)) + + async def probe(): + async with mcp_client(build_server(bridge), modern=True) as client: + names = [t.name for t in (await client.list_tools()).tools] + result = await client.call_tool("continue_attention", action_arguments()) + return names, wire(result) + + names, result = anyio.run(probe) + assert "continue_attention" in names + assert result["isError"] is True + assert "form elicitation" in result["content"][0]["text"] + assert calls == [] + + +def test_cli_stdio_discovers_and_calls_without_protocol_contamination(bundles_root, tmp_path): + async def probe(): + with anyio.fail_after(15): + params = StdioServerParameters( + command=sys.executable, + args=[ + "-m", + "openadapt_agent.mcp", + "--bundles", + str(bundles_root), + "--runs-dir", + str(tmp_path / "runs"), + ], + ) + async with stdio_client(params) as streams: + async with ClientSession(*streams) as client: + initialized = wire(await client.initialize()) + names = [t.name for t in (await client.list_tools()).tools] + result = wire(await client.call_tool("list_workflows", {})) + refused = wire(await client.call_tool("continue_attention", action_arguments())) + return initialized, names, result, refused + + initialized, names, result, refused = anyio.run(probe) + assert initialized["serverInfo"]["name"] == "openadapt-agent" + assert "list_workflows" in names + assert result["isError"] is False + assert json.loads(result["content"][0]["text"])["run_tools_enabled"] is False + assert refused["isError"] is True -@pytest.mark.parametrize( - ("session", "error"), - [ - (ElicitationSession(supported=False), "form elicitation"), - (ElicitationSession(action="decline"), "declined or cancelled"), - (ElicitationSession(confirmed=False), "declined or cancelled"), - ], -) -def test_attended_action_refuses_missing_or_declined_human_confirmation(session, error): - with pytest.raises(BridgeError, match=error): - anyio.run( - _confirm_attended_action, - ElicitationServer(session), - "continue_attention", - ) +def test_run_tool_preserves_real_flow_admission_refusal(bundles_root, tmp_path, mcp_client): + from openadapt_agent.runner import RunnerConfig + + config = RunnerConfig(runs_dir=tmp_path / "runs", timeout_s=15) + server = build_server(AgentBridge(bundles_root, config, allow_run=True)) + + async def call(): + async with mcp_client(server) as client: + tool = next( + t for t in (await client.list_tools()).tools if t.name.startswith("run_workflow_") + ) + return await client.call_tool(tool.name, {"note": "Synthetic test input"}) + + result = anyio.run(call) + payload = json.loads(result.content[0].text) + # This unsigned synthetic bundle cannot actuate. Execute the installed + # Flow CLI and check its real governed refusal, without a subprocess stub. + assert payload["status"] == "refused" + assert payload["success"] is False + assert payload["sealed"] is False + assert "protected" not in payload diff --git a/uv.lock b/uv.lock index 77146b2..3b8eca9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.13" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' or sys_platform != 'emscripten'", +] [[package]] name = "annotated-types" @@ -251,6 +255,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -267,12 +284,29 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -334,15 +368,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -352,9 +386,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, ] [[package]] @@ -439,6 +486,7 @@ source = { editable = "." } dependencies = [ { name = "anyio" }, { name = "cryptography" }, + { name = "jsonschema" }, { name = "mcp" }, { name = "openadapt-flow" }, ] @@ -458,7 +506,8 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0" }, { name = "build", marker = "extra == 'dev'", specifier = ">=1.2.0" }, { name = "cryptography", specifier = ">=50.0.0" }, - { name = "mcp", specifier = ">=1.28,<2" }, + { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "mcp", specifier = ">=1.28,<3" }, { name = "openadapt-flow", specifier = ">=1.26.0,<2" }, { name = "openadapt-flow", extras = ["browser"], marker = "extra == 'tutorial'", specifier = ">=1.26.0,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, @@ -511,6 +560,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -725,20 +786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pyee" version = "13.0.1" @@ -804,15 +851,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1117,6 +1155,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"