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
62 changes: 62 additions & 0 deletions packages/langchain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,68 @@ 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 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.

## Using tools outside the agent

`get_access_context()` normally only works inside an agent run, because the
Expand Down
99 changes: 99 additions & 0 deletions packages/mcp/src/keycardai/mcp/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,105 @@ 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"],
)
# 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
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
Expand Down
Loading
Loading