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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]"
Expand Down
7 changes: 7 additions & 0 deletions docs/DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ 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",
# 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",
Expand Down
72 changes: 56 additions & 16 deletions src/openadapt_agent/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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(
Expand All @@ -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 {})
Expand Down Expand Up @@ -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


Expand Down
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 11 additions & 16 deletions tests/test_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from pathlib import Path

import anyio
import mcp.types as types
import pytest

from openadapt_agent.authoring import (
Expand Down Expand Up @@ -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)
Expand All @@ -397,28 +397,23 @@ 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
assert "observe" in both
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
Expand Down
Loading