From 30d19d42ebeb5abf103be46a8f243f7980e6f3fd Mon Sep 17 00:00:00 2001 From: iztaylor Date: Thu, 18 Jun 2026 10:14:21 -0400 Subject: [PATCH] feat: scripted MCP client (auth_headers + connect/list/call); offline tests pass --- lib/__init__.py | 0 lib/mcp_client.py | 39 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_mcp_client.py | 13 +++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 lib/__init__.py create mode 100644 lib/mcp_client.py create mode 100644 tests/test_mcp_client.py diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/mcp_client.py b/lib/mcp_client.py new file mode 100644 index 0000000..4dc1866 --- /dev/null +++ b/lib/mcp_client.py @@ -0,0 +1,39 @@ +"""Minimal scripted MCP client for the Arcade eval. + +Headless auth (confirmed from Arcade docs, see ../LIVE-POC.md): + Authorization: Bearer + Arcade-User-ID: (any stable string; an email works) + +`auth_headers` is pure (no deps) so it unit-tests offline. The `mcp` SDK is imported +lazily inside the async helpers so this module loads even before deps are installed. +""" +from __future__ import annotations + + +def auth_headers(api_key: str, user_id: str) -> dict[str, str]: + """Build the headless MCP auth headers for a given Arcade user_id.""" + return {"Authorization": f"Bearer {api_key}", "Arcade-User-ID": user_id} + + +async def connect_and_list(mcp_url: str, headers: dict[str, str]) -> list[dict]: + """Connect to an MCP gateway over streamable HTTP and return the tool list.""" + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + async with streamablehttp_client(mcp_url, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + return [t.model_dump() for t in result.tools] + + +async def call_tool(mcp_url: str, headers: dict[str, str], name: str, args: dict) -> dict: + """Invoke a single tool through an MCP gateway and return the result payload.""" + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + async with streamablehttp_client(mcp_url, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(name, args) + return result.model_dump() diff --git a/pyproject.toml b/pyproject.toml index bcfc860..e2d7387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,4 @@ dev = ["pytest>=8.0"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["."] diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py new file mode 100644 index 0000000..9f3338e --- /dev/null +++ b/tests/test_mcp_client.py @@ -0,0 +1,13 @@ +from lib.mcp_client import auth_headers + + +def test_auth_headers_sets_key_and_user(): + h = auth_headers("k_test", "user-a@servicetitan.com") + assert h["Authorization"] == "Bearer k_test" + assert h["Arcade-User-ID"] == "user-a@servicetitan.com" + + +def test_auth_headers_distinct_users(): + a = auth_headers("k", "alice") + b = auth_headers("k", "bob") + assert a["Arcade-User-ID"] != b["Arcade-User-ID"]