From e54449f31e045c068e29422279e781212126e53c Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Fri, 28 Aug 2026 23:13:26 +0000 Subject: [PATCH 1/3] feat(keycardai-mcp): interrupt-compatible auth mode for the langchain adapter An agent that combines keycardai-langchain's KeycardGrantMiddleware with this adapter had two auth UXs: the middleware pauses the run with an `authorization_required` interrupt, while the adapter handed the model auth-request tools. `interrupt_on_auth=True` makes the adapter raise the same payload the middleware's `_interrupt_payload` produces, with MCP servers in place of resource URLs, sourced from the pending challenge of a session whose `requires_user_action` is true. Off by default: the auth-tools path is untouched unless the mode is enabled. Also adds `tool_allowlist`, so a large server cannot flood the model's context, and `get_lazy_tools()` (`list_mcp_tools` / `call_mcp_tool`) for agents whose tool list must exist before any user has connected: they connect on first call and then expose the server's real tool schemas rather than a hand-written wrapper that hides its filtering parameters. Co-Authored-By: Larry Osakwe --- .../mcp/src/keycardai/mcp/client/README.md | 96 ++++++ .../client/integrations/langchain_agents.py | 280 +++++++++++++++++- .../integrations/test_langchain_interrupt.py | 245 +++++++++++++++ 3 files changed, 618 insertions(+), 3 deletions(-) create mode 100644 packages/mcp/tests/keycardai/mcp/client/integrations/test_langchain_interrupt.py diff --git a/packages/mcp/src/keycardai/mcp/client/README.md b/packages/mcp/src/keycardai/mcp/client/README.md index 2bb9cac6..7b2fae56 100644 --- a/packages/mcp/src/keycardai/mcp/client/README.md +++ b/packages/mcp/src/keycardai/mcp/client/README.md @@ -714,6 +714,102 @@ async with LangChainClient(mcp_client, auth_tool_handler=handler) as client: --- +### Combining MCP tools with keycardai-langchain grants + +An agent often needs both: REST tools whose credentials Keycard brokers per +tool call (`KeycardGrantMiddleware` from `keycardai-langchain`), and MCP tools +whose server runs its own interactive OAuth. The two used to surface auth +differently — the middleware pauses the run with an `authorization_required` +interrupt, while this adapter handed the model a `request_authentication` tool +— so one chat surface had to render two auth UXs. + +`interrupt_on_auth=True` makes the MCP adapter raise the *same* +`authorization_required` payload as the middleware: + +```python +{ + "type": "authorization_required", + "resources": ["linear"], # MCP servers, not resource URLs + "authorization_url": "https://.../authorize?state=...", + "errors": {"linear": {"code": "authorization_required", "message": "..."}}, + "message": "Access to the resources above has not been granted yet. ...", +} +``` + +The URL comes from the pending challenge of a session whose +`requires_user_action` is true (`challenges[0]["authorization_url"]`). Interrupt +mode requires langgraph and a checkpointer, and it replaces the auth tools: +`get_auth_tools()` returns `[]` while it is on. It is **opt-in** — without it +the auth-tool behavior is exactly as documented above. + +**Which one grants what.** The middleware brokers tokens for resources *your* +tools call. The MCP server's own OAuth is between the user and that server, so +the middleware must exchange nothing for an MCP-backed tool — map it to an +empty resource list: + +```python +KeycardGrantMiddleware( + zone_url=ZONE_URL, + resources=[CALENDAR], # REST tools get brokered tokens + tool_resources={"call_mcp_tool": []}, # MCP-backed tool: nothing to exchange + authorization_url=lambda resources: f"{BASE_URL}/authorize?r={resources[0]}", +) +``` + +**Wiring, end to end.** One coordinator and one callback route serve every +user: + +```python +from starlette.responses import HTMLResponse +from keycardai.mcp.client import ClientManager +from keycardai.mcp.client.auth.coordinators import StarletteAuthCoordinator +from keycardai.mcp.client.storage import InMemoryBackend +from keycardai.mcp.client.integrations import langchain_agents + +coordinator = StarletteAuthCoordinator( + redirect_uri=f"{BASE_URL}/auth/mcp/callback", + backend=InMemoryBackend(), +) +manager = ClientManager(servers, auth_coordinator=coordinator) + + +async def mcp_callback(request): + await coordinator.handle_completion(dict(request.query_params)) + return HTMLResponse("Authorized. Return to the chat and continue.") + + +async def mcp_tools_for(user_email: str): + client = await manager.get_client(context_id=user_email) + adapter = langchain_agents.LangChainClient( + client, + interrupt_on_auth=True, + tool_allowlist=["list_issues", "create_issue"], + ) + await client.connect() + return await adapter.get_tools() +``` + +The session reconnects itself once `handle_completion` fires, so resuming the +interrupted run is all that is left to do. + +**Exposing the server's real tools.** `create_agent` fixes its tool list when +the module loads, before any user has connected, which is why hand-written +wrapper tools appear: they hide the server's parameters (a wrapper over +`list_issues` that drops `state`, `query`, `team` and `project` cannot answer +"what is in progress?"). Two supported ways out: + +- **Build the agent after `connect()`** — per request, as `mcp_tools_for()` + above does. `get_tools()` reflects the server's real schemas. +- **Lazy tools** — `await adapter.get_lazy_tools()` returns `list_mcp_tools` + and `call_mcp_tool`, which connect on first call and then report and invoke + the server's actual tools with their full input schemas. Use these when the + tool list genuinely has to exist at import time. + +`tool_allowlist=[...]` restricts which server tools are exposed either way; a +67-tool server would otherwise flood the model's context window. + +--- + ### LangChain ```bash diff --git a/packages/mcp/src/keycardai/mcp/client/integrations/langchain_agents.py b/packages/mcp/src/keycardai/mcp/client/integrations/langchain_agents.py index de6e21af..c741fd65 100644 --- a/packages/mcp/src/keycardai/mcp/client/integrations/langchain_agents.py +++ b/packages/mcp/src/keycardai/mcp/client/integrations/langchain_agents.py @@ -6,21 +6,94 @@ - System prompt generation with auth context - MCP tools converted to LangChain tools - Auth request tools for agent +- Optional interrupt mode, so an MCP auth challenge pauses the run with the + same `authorization_required` payload keycardai-langchain's + KeycardGrantMiddleware raises +- Lazy tools for agents built before any user has connected """ import json import logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from typing import Any from langchain_core.tools import StructuredTool from pydantic import BaseModel, Field, create_model from ..client import Client +from ..types import AuthChallenge from .auth_tools import AuthToolHandler, DefaultAuthToolHandler logger = logging.getLogger(__name__) +AUTHORIZATION_REQUIRED = "authorization_required" + +_AUTHORIZATION_MESSAGE = ( + "Access to the resources above has not been granted yet. " + "Open the authorization URL to grant it, then resume the run." +) + + +def build_authorization_interrupt_payload( + challenges: Sequence[AuthChallenge], +) -> dict[str, Any]: + """The `authorization_required` interrupt payload for pending MCP challenges. + + Deliberately the same shape keycardai-langchain's KeycardGrantMiddleware + produces for a missing grant, so an agent that combines brokered REST tools + (middleware) with MCP tools (this adapter) hands its chat surface one + payload to render, not two. MCP servers take the place of resource URLs in + the `resources` and `errors` fields. + + Args: + challenges: Pending challenges, as returned by + `Client.get_auth_challenges()`. + + Returns: + Interrupt payload with `type`, `resources`, `authorization_url`, + `errors` and `message`. + """ + servers = [challenge["server"] for challenge in challenges] + authorization_url = next( + ( + challenge["authorization_url"] + for challenge in challenges + if challenge.get("authorization_url") + ), + None, + ) + return { + "type": AUTHORIZATION_REQUIRED, + "resources": servers, + "authorization_url": authorization_url, + "errors": { + challenge["server"]: { + "code": AUTHORIZATION_REQUIRED, + "message": ( + f"MCP server '{challenge['server']}' has not been authorized yet." + ), + } + for challenge in challenges + }, + "message": _AUTHORIZATION_MESSAGE, + } + + +def _interrupt(payload: dict[str, Any]) -> None: + """Raise a LangGraph interrupt. + + Imported lazily: langgraph is only needed by callers that opt into + interrupt mode, and the adapter itself depends on langchain-core alone. + """ + try: + from langgraph.types import interrupt + except ImportError as e: + raise RuntimeError( + "interrupt_on_auth requires langgraph. Install it with " + "`uv add langgraph` (it ships with langchain 1.x agents)." + ) from e + interrupt(payload) + class LangChainClient: """ @@ -46,6 +119,8 @@ def __init__( auth_tool_handler: AuthToolHandler | None = None, auth_hook_closure: Callable[[], Awaitable[None]] | None = None, auth_prompt: str | None = None, + interrupt_on_auth: bool = False, + tool_allowlist: Sequence[str] | None = None, ): """ Initialize adapter. @@ -58,6 +133,16 @@ def __init__( For custom flows (Slack, email, etc.), provide your own handler. auth_hook_closure: Optional async function called when auth is needed auth_prompt: Optional custom authentication prompt to include in system message + interrupt_on_auth: Opt in to interrupt mode. A tool that hits an + auth challenge pauses the run with the same + `authorization_required` interrupt keycardai-langchain's + KeycardGrantMiddleware raises, instead of handing the model + auth-request tools. Off by default: the auth-tools behavior is + unchanged unless this is set. Requires langgraph and a + checkpointer on the agent. + tool_allowlist: Optional server tool names to expose. Everything + else the server advertises is hidden, which keeps a large + server (say 67 tools) from flooding the model's context. """ self._mcp_client = mcp_client self._auth_tool_handler = auth_tool_handler or DefaultAuthToolHandler() @@ -66,6 +151,8 @@ def __init__( self._auth_hook_closure = auth_hook_closure self._tools_cache: list[StructuredTool] = [] self.auth_prompt = auth_prompt + self._interrupt_on_auth = interrupt_on_auth + self._tool_allowlist = list(tool_allowlist) if tool_allowlist else None async def __aenter__(self) -> "LangChainClient": """ @@ -168,6 +255,8 @@ async def get_tools(self) -> list[StructuredTool]: tool_infos = await self._mcp_client.list_tools(server_name) for tool_info in tool_infos: + if not self._is_allowed(tool_info.tool.name): + continue langchain_tool = self._convert_mcp_tool_to_langchain( tool_info.tool, tool_info.server ) @@ -203,6 +292,8 @@ def _convert_mcp_tool_to_langchain( async def invoke_tool(**kwargs) -> str: """Invoke the MCP tool.""" + if self._interrupt_on_auth: + await self._interrupt_if_authorization_required() try: result = await self._mcp_client.call_tool( tool_name, kwargs, server_name=server_name @@ -309,6 +400,166 @@ def _json_type_to_python(self, json_type: str) -> type: } return type_map.get(json_type, str) + def _is_allowed(self, tool_name: str) -> bool: + """Whether a server tool passes the configured allowlist.""" + return self._tool_allowlist is None or tool_name in self._tool_allowlist + + async def _connect_and_refresh(self) -> None: + """Connect the underlying client and re-read auth state. + + Lazy tools call this on invocation: the client for a given user may + only be built once that user shows up, long after the agent's tool + list was assembled. + """ + await self._mcp_client.connect() + self._pending_challenges = await self._mcp_client.get_auth_challenges() + try: + tool_infos = await self._mcp_client.list_tools() + self._authenticated_servers = list({info.server for info in tool_infos}) + except Exception as e: + logger.error(f"Error listing tools: {e}", exc_info=True) + self._authenticated_servers = [] + self._tools_cache = [] + + async def _pending_authorization_challenges(self) -> list[AuthChallenge]: + """Challenges for sessions that are waiting on the user, if any. + + `session.requires_user_action` is the authoritative signal (status + AUTH_PENDING); the challenge carries the `authorization_url` to show. + """ + waiting = [ + name + for name, session in self._mcp_client.sessions.items() + if session.requires_user_action + ] + if not waiting: + return [] + challenges = await self._mcp_client.get_auth_challenges() + return [c for c in challenges if c["server"] in waiting] + + async def _interrupt_if_authorization_required(self) -> None: + """Pause the run when a connected session is waiting on authorization.""" + challenges = await self._pending_authorization_challenges() + if not challenges: + return + self._pending_challenges = list(challenges) + _interrupt(build_authorization_interrupt_payload(challenges)) + + async def get_lazy_tools(self) -> list[StructuredTool]: + """ + Tools that can be bound before any user has connected. + + `create_agent` fixes its tool list when the module loads, but an MCP + server's real tools are only knowable once a user has a connected + session. These tools connect on first call and then reflect the + server's actual schemas: + + - `list_mcp_tools` returns the server's tools with their full input + schemas (filtered by `tool_allowlist`), so the model calls them with + the server's own parameters rather than a hand-written subset. + - `call_mcp_tool` invokes one of them by name. + + Both connect the client on invocation. In interrupt mode a pending + challenge pauses the run; otherwise the auth message is returned to + the model. Once a session is authorized, `get_tools()` returns the + server's tools as first-class LangChain tools, which is the better + binding whenever the agent can be built after `connect()`. + + Returns: + List of lazy LangChain tools + """ + + def _allowed_infos(tool_infos): + return [i for i in tool_infos if self._is_allowed(i.tool.name)] + + async def _prepare() -> str | None: + """Connect, and report why tools are unavailable when they are.""" + await self._connect_and_refresh() + if self._interrupt_on_auth: + await self._interrupt_if_authorization_required() + return None + challenges = await self._pending_authorization_challenges() + if not challenges: + return None + url = challenges[0].get("authorization_url") + return ( + f"Authorization required for {challenges[0]['server']}. " + f"Ask the user to visit: {url}" + ) + + async def list_mcp_tools() -> str: + pending = await _prepare() + if pending: + return pending + infos = _allowed_infos(await self._mcp_client.list_tools()) + return json.dumps( + [ + { + "name": info.tool.name, + "server": info.server, + "description": info.tool.description, + "input_schema": getattr(info.tool, "input_schema", {}), + } + for info in infos + ], + indent=2, + ) + + async def call_mcp_tool(tool_name: str, arguments: dict | None = None) -> str: + pending = await _prepare() + if pending: + return pending + if not self._is_allowed(tool_name): + return f"Tool '{tool_name}' is not available." + infos = _allowed_infos(await self._mcp_client.list_tools()) + info = next((i for i in infos if i.tool.name == tool_name), None) + if info is None: + available = ", ".join(i.tool.name for i in infos) + return f"Tool '{tool_name}' not found. Available tools: {available}" + tool = self._convert_mcp_tool_to_langchain(info.tool, info.server) + return await tool.coroutine(**(arguments or {})) + + allowlist_note = ( + f" Available tools: {', '.join(self._tool_allowlist)}." + if self._tool_allowlist + else "" + ) + + class CallMcpToolInput(BaseModel): + """Input for call_mcp_tool.""" + + tool_name: str = Field( + description="Name of the MCP tool, as returned by list_mcp_tools" + ) + arguments: dict = Field( + default_factory=dict, + description=( + "Arguments for the tool, matching the input_schema " + "list_mcp_tools reported for it" + ), + ) + + return [ + StructuredTool.from_function( + name="list_mcp_tools", + description=( + "List the MCP tools available to this user, with the input " + "schema of each. Call this before call_mcp_tool so the tool " + "is called with the server's own parameters." + allowlist_note + ), + coroutine=list_mcp_tools, + ), + StructuredTool( + name="call_mcp_tool", + description=( + "Call an MCP tool by name with the arguments its input " + "schema declares." + allowlist_note + ), + coroutine=call_mcp_tool, + args_schema=CallMcpToolInput, + ), + ] + async def get_auth_tools(self) -> list[StructuredTool]: """ Get authentication request tools for the agent. @@ -316,6 +567,10 @@ async def get_auth_tools(self) -> list[StructuredTool]: Returns a tool that allows the agent to request user authentication when needed. If all services are authenticated, returns empty list. + In interrupt mode there is no auth tool: the run pauses with an + `authorization_required` interrupt instead of asking the model to + request authorization, so this returns an empty list. + Returns: List with one auth request tool (or empty if no auth needed) @@ -326,7 +581,7 @@ async def get_auth_tools(self) -> list[StructuredTool]: >>> # If all authenticated: >>> # [] """ - if not self._pending_challenges: + if self._interrupt_on_auth or not self._pending_challenges: return [] pending_services = [c["server"] for c in self._pending_challenges] @@ -387,6 +642,8 @@ def create_client( mcp_client: Client, auth_tool_handler: AuthToolHandler | None = None, auth_hook_closure: Callable[[], Awaitable[None]] | None = None, + interrupt_on_auth: bool = False, + tool_allowlist: Sequence[str] | None = None, ) -> LangChainClient: """ Get LangChain agents adapter for MCP client. @@ -400,6 +657,8 @@ def create_client( Built-in options: SlackAuthToolHandler, ConsoleAuthToolHandler Default: DefaultAuthToolHandler (returns message for agent) auth_hook_closure: Optional async function called when auth is needed + interrupt_on_auth: Opt in to interrupt mode (see LangChainClient) + tool_allowlist: Optional server tool names to expose Returns: LangChain client adapter @@ -450,6 +709,21 @@ def create_client( ... {"messages": [{"role": "user", "content": "Hi, my name is Bob"}]}, ... {"configurable": {"thread_id": "123"}}, ... ) + + Example - Interrupt mode alongside keycardai-langchain's middleware: + >>> client = langchain_agents.create_client( + ... mcp_client, + ... interrupt_on_auth=True, + ... tool_allowlist=["list_issues", "create_issue"], + ... ) + >>> # A pending MCP challenge now pauses the run with the same + >>> # `authorization_required` payload KeycardGrantMiddleware raises. """ - return LangChainClient(mcp_client, auth_tool_handler, auth_hook_closure) + return LangChainClient( + mcp_client, + auth_tool_handler, + auth_hook_closure, + interrupt_on_auth=interrupt_on_auth, + tool_allowlist=tool_allowlist, + ) diff --git a/packages/mcp/tests/keycardai/mcp/client/integrations/test_langchain_interrupt.py b/packages/mcp/tests/keycardai/mcp/client/integrations/test_langchain_interrupt.py new file mode 100644 index 00000000..598e6809 --- /dev/null +++ b/packages/mcp/tests/keycardai/mcp/client/integrations/test_langchain_interrupt.py @@ -0,0 +1,245 @@ +"""Interrupt mode in the LangChain adapter. + +The point of the mode is that an agent combining keycardai-langchain's +KeycardGrantMiddleware (brokered REST tools) with this adapter (MCP tools) +raises one `authorization_required` payload, not two shapes. So these tests +compare against the middleware's own `_interrupt_payload` where it is +installed, and exercise the interrupt through a real LangGraph run rather than +a stubbed `interrupt`. +""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("langchain") +pytest.importorskip("langgraph") + +from langgraph.checkpoint.memory import InMemorySaver # noqa: E402 +from langgraph.graph import END, START, StateGraph # noqa: E402 +from mcp.types import Tool # noqa: E402 +from typing_extensions import TypedDict # noqa: E402 + +from keycardai.mcp.client.integrations.langchain_agents import ( # noqa: E402 + LangChainClient, + build_authorization_interrupt_payload, +) + +AUTH_URL = "https://zone.example/authorize?state=abc" + +LIST_ISSUES = Tool( + name="list_issues", + description="List issues", + inputSchema={ + "type": "object", + "properties": { + "state": {"type": "string", "description": "Issue state"}, + "team": {"type": "string", "description": "Team"}, + }, + }, +) + + +class FakeSession: + def __init__(self, requires_user_action: bool): + self.requires_user_action = requires_user_action + + +class FakeToolInfo: + def __init__(self, tool: Tool, server: str): + self.tool = tool + self.server = server + + +class FakeClient: + """The client surface the adapter uses, with a scriptable auth state.""" + + def __init__(self, *, requires_user_action: bool, tools: list[Tool] | None = None): + self.sessions = {"linear": FakeSession(requires_user_action)} + self._tools = tools if tools is not None else [LIST_ISSUES] + self.calls: list[tuple[str, dict[str, Any]]] = [] + + async def connect(self, *args, **kwargs) -> None: + return None + + async def get_auth_challenges(self, server_name: str | None = None): + if not self.sessions["linear"].requires_user_action: + return [] + return [{"server": "linear", "authorization_url": AUTH_URL, "state": "abc"}] + + async def list_tools(self, server_name: str | None = None): + return [FakeToolInfo(tool, "linear") for tool in self._tools] + + async def call_tool(self, tool_name: str, arguments: dict, server_name=None): + self.calls.append((tool_name, arguments)) + return "ok" + + +class RunState(TypedDict): + result: str + + +async def run_tool_in_graph(tool, arguments: dict[str, Any]) -> dict[str, Any]: + """Invoke a tool inside a checkpointed graph and return the run's output. + + A LangGraph interrupt only exists inside a run, so the tool has to be + called from a node for the interrupt path to be the real one. + """ + + async def node(state: RunState) -> RunState: + return {"result": await tool.coroutine(**arguments)} + + graph = StateGraph(RunState) + graph.add_node("call", node) + graph.add_edge(START, "call") + graph.add_edge("call", END) + compiled = graph.compile(checkpointer=InMemorySaver()) + return await compiled.ainvoke({"result": ""}, {"configurable": {"thread_id": "t1"}}) + + +@pytest.fixture +def pending_client() -> FakeClient: + return FakeClient(requires_user_action=True) + + +def test_payload_shape_matches_the_middleware(pending_client: FakeClient) -> None: + """Same keys, same `type`, same message as KeycardGrantMiddleware emits.""" + middleware_module = pytest.importorskip("keycardai.langchain.middleware") + + access = MagicMock() + access.get_resource_error.return_value = {"code": "access_denied"} + middleware = middleware_module.KeycardGrantMiddleware( + zone_url="https://zone.example", + resources=["https://api.example"], + authorization_url=AUTH_URL, + ) + reference = middleware._interrupt_payload(["https://api.example"], access) + + payload = build_authorization_interrupt_payload( + [{"server": "linear", "authorization_url": AUTH_URL}] + ) + + assert payload.keys() == reference.keys() + assert payload["type"] == reference["type"] == "authorization_required" + assert payload["message"] == reference["message"] + assert payload["authorization_url"] == AUTH_URL + assert payload["resources"] == ["linear"] + assert set(payload["errors"]) == {"linear"} + + +@pytest.mark.asyncio +async def test_interrupt_mode_pauses_the_run(pending_client: FakeClient) -> None: + client = LangChainClient(pending_client, interrupt_on_auth=True) + tool = client._convert_mcp_tool_to_langchain(LIST_ISSUES, "linear") + + result = await run_tool_in_graph(tool, {"state": "in progress"}) + + (paused,) = result["__interrupt__"] + assert paused.value == build_authorization_interrupt_payload( + [{"server": "linear", "authorization_url": AUTH_URL}] + ) + assert pending_client.calls == [], "tool ran despite the pending challenge" + + +@pytest.mark.asyncio +async def test_interrupt_mode_calls_through_once_authorized() -> None: + authorized = FakeClient(requires_user_action=False) + client = LangChainClient(authorized, interrupt_on_auth=True) + tool = client._convert_mcp_tool_to_langchain(LIST_ISSUES, "linear") + + result = await run_tool_in_graph(tool, {"state": "in progress"}) + + assert result["result"] == "ok" + assert authorized.calls == [("list_issues", {"state": "in progress"})] + + +@pytest.mark.asyncio +async def test_default_mode_does_not_interrupt(pending_client: FakeClient) -> None: + """Off by default: the same pending challenge changes nothing.""" + client = LangChainClient(pending_client) + tool = client._convert_mcp_tool_to_langchain(LIST_ISSUES, "linear") + + result = await run_tool_in_graph(tool, {"state": "in progress"}) + + assert "__interrupt__" not in result + assert result["result"] == "ok" + assert pending_client.calls == [("list_issues", {"state": "in progress"})] + + +@pytest.mark.asyncio +async def test_default_mode_still_offers_the_auth_tool( + pending_client: FakeClient, +) -> None: + client = LangChainClient(pending_client) + async with client: + tools = await client.get_auth_tools() + + assert [t.name for t in tools] == ["request_authentication"] + + +@pytest.mark.asyncio +async def test_interrupt_mode_replaces_the_auth_tool( + pending_client: FakeClient, +) -> None: + client = LangChainClient(pending_client, interrupt_on_auth=True) + async with client: + assert await client.get_auth_tools() == [] + + +@pytest.mark.asyncio +async def test_allowlist_hides_other_server_tools() -> None: + other = Tool(name="create_issue", description="Create", inputSchema={}) + authorized = FakeClient( + requires_user_action=False, tools=[LIST_ISSUES, other] + ) + client = LangChainClient(authorized, tool_allowlist=["list_issues"]) + + async with client: + tools = await client.get_tools() + + assert [t.name for t in tools] == ["list_issues"] + + +@pytest.mark.asyncio +async def test_lazy_tools_report_the_servers_real_schema() -> None: + """The reason lazy tools exist: the server's own parameters reach the model.""" + authorized = FakeClient(requires_user_action=False) + client = LangChainClient(authorized) + + tools = await client.get_lazy_tools() + listed = await next(t for t in tools if t.name == "list_mcp_tools").coroutine() + + assert '"state"' in listed and '"team"' in listed + + +@pytest.mark.asyncio +async def test_lazy_tools_interrupt_before_connecting( + pending_client: FakeClient, +) -> None: + client = LangChainClient(pending_client, interrupt_on_auth=True) + tools = await client.get_lazy_tools() + call_mcp_tool = next(t for t in tools if t.name == "call_mcp_tool") + + result = await run_tool_in_graph( + call_mcp_tool, {"tool_name": "list_issues", "arguments": {"state": "open"}} + ) + + (paused,) = result["__interrupt__"] + assert paused.value["type"] == "authorization_required" + assert paused.value["authorization_url"] == AUTH_URL + + +@pytest.mark.asyncio +async def test_lazy_call_passes_arguments_to_the_server_tool() -> None: + authorized = FakeClient(requires_user_action=False) + client = LangChainClient(authorized) + tools = await client.get_lazy_tools() + call_mcp_tool = next(t for t in tools if t.name == "call_mcp_tool") + + result = await call_mcp_tool.coroutine( + tool_name="list_issues", arguments={"state": "in progress"} + ) + + assert result == "ok" + assert authorized.calls == [("list_issues", {"state": "in progress"})] From 53a5c801e9495bbc7d6088dcd22e6cc5b61f4248 Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Fri, 28 Aug 2026 23:13:32 +0000 Subject: [PATCH 2/3] docs(keycardai-langchain): document MCP tools alongside brokered grants Covers when to use middleware grants versus the MCP client, the empty tool_resources mapping for MCP-backed tools, the shared /auth/mcp/callback route wired to coordinator.handle_completion(...), and the MCP adapter's opt-in interrupt mode. Co-Authored-By: Larry Osakwe --- packages/langchain/README.md | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/langchain/README.md b/packages/langchain/README.md index e4d7b834..d6fc6440 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -255,6 +255,66 @@ Both require a checkpointer. Two details worth knowing: Scope granularity falls out of this for free: if a user has granted read but not write, the read call succeeds and the write call is the one that pauses. +## MCP tools in the same agent + +Agents commonly mix two kinds of access: REST tools whose credentials this +middleware brokers per tool call, and MCP servers that run their own +interactive OAuth through `keycardai-mcp`. Both can pause the run with the +same `authorization_required` payload, so your chat surface renders one auth +UX: + +```python +from keycardai.mcp.client.integrations import langchain_agents + +adapter = langchain_agents.LangChainClient( + mcp_client, + interrupt_on_auth=True, # opt in; off by default + tool_allowlist=["list_issues", "create_issue"], # keep the context small +) +``` + +Without `interrupt_on_auth` the MCP adapter keeps its own UX: it hands the +model a `request_authentication` tool instead of interrupting. + +Three things to get right when the two are combined: + +**MCP-backed tools exchange nothing.** The MCP server's OAuth grant belongs to +the user and that server, not to Keycard's exchange, so map those tools to an +empty resource list or the middleware will try to broker a token they do not +need: + +```python +KeycardGrantMiddleware( + zone_url=ZONE_URL, + resources=[CALENDAR], # REST tools + tool_resources={"call_mcp_tool": []}, # MCP-backed tool + authorization_url=lambda resources: f"{BASE_URL}/authorize?r={resources[0]}", +) +``` + +**One callback route.** The MCP client's coordinator owns the redirect, and a +single route completes the flow for every user: + +```python +coordinator = StarletteAuthCoordinator( + redirect_uri=f"{BASE_URL}/auth/mcp/callback", + backend=InMemoryBackend(), +) +manager = ClientManager(servers, auth_coordinator=coordinator) + + +async def mcp_callback(request): + await coordinator.handle_completion(dict(request.query_params)) + return HTMLResponse("Authorized. Return to the chat and continue.") +``` + +**Per-user clients, tools bound after connect.** `manager.get_client(context_id=user_email)` +gives each user their own session; build the agent's MCP tools after +`client.connect()` so the model sees the server's real tool schemas (or use +`adapter.get_lazy_tools()` when the tool list must exist at import time). The +MCP client's [README](../mcp/src/keycardai/mcp/client/README.md#combining-mcp-tools-with-keycardai-langchain-grants) +covers that side in full. + ## Using tools outside the agent `get_access_context()` normally only works inside an agent run, because the From 1645dc7b0c363ae70cc50e80b54897a20a68071a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 28 Aug 2026 16:59:25 -0700 Subject: [PATCH 3/3] docs(keycardai-mcp): fix the build-after-connect snippet get_tools() reads the server list that only __aenter__ (or the lazy path) populates; the documented pattern called client.connect() directly and returned zero tools every time. Enter the adapter instead. --- packages/langchain/README.md | 6 ++++-- packages/mcp/src/keycardai/mcp/client/README.md | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/langchain/README.md b/packages/langchain/README.md index d6fc6440..8ea64511 100644 --- a/packages/langchain/README.md +++ b/packages/langchain/README.md @@ -309,8 +309,10 @@ async def mcp_callback(request): ``` **Per-user clients, tools bound after connect.** `manager.get_client(context_id=user_email)` -gives each user their own session; build the agent's MCP tools after -`client.connect()` so the model sees the server's real tool schemas (or use +gives each user their own session; build the agent's MCP tools inside +`async with adapter:` (entering connects and discovers the authenticated +servers; `get_tools()` is empty without it) so the model sees the server's +real tool schemas (or use `adapter.get_lazy_tools()` when the tool list must exist at import time). The MCP client's [README](../mcp/src/keycardai/mcp/client/README.md#combining-mcp-tools-with-keycardai-langchain-grants) covers that side in full. diff --git a/packages/mcp/src/keycardai/mcp/client/README.md b/packages/mcp/src/keycardai/mcp/client/README.md index 7b2fae56..d2f34eb9 100644 --- a/packages/mcp/src/keycardai/mcp/client/README.md +++ b/packages/mcp/src/keycardai/mcp/client/README.md @@ -785,8 +785,11 @@ async def mcp_tools_for(user_email: str): interrupt_on_auth=True, tool_allowlist=["list_issues", "create_issue"], ) - await client.connect() - return await adapter.get_tools() + # Entering the adapter connects the client and discovers which servers + # are authenticated; get_tools() is empty without it. Exit is a no-op, + # so the returned tools stay valid. + async with adapter: + return await adapter.get_tools() ``` The session reconnects itself once `handle_completion` fires, so resuming the