Skip to content

Commit 47b2bdf

Browse files
committed
feat(keycardai-mcp): headless clients
1 parent d6dac10 commit 47b2bdf

File tree

4 files changed

+119
-26
lines changed

4 files changed

+119
-26
lines changed

packages/mcp/src/keycardai/mcp/client/CONTRIBUTORS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,9 +1182,12 @@ Clear boundaries help maintain clean architecture.
11821182

11831183
**LocalAuthCoordinator** (CLI/Desktop):
11841184
- Runs embedded HTTP callback server on localhost
1185-
- Blocks on redirects using asyncio.Event (waits for callback)
1186-
- Opens browser automatically (`webbrowser.open()`)
1185+
- Blocks on redirects using asyncio.Event (waits for callback) - **configurable**
1186+
- Opens browser automatically (`webbrowser.open()`) - **configurable**
11871187
- Suitable for single-process applications
1188+
- Parameters:
1189+
- `auto_open_browser` (default: `True`): Whether to automatically open browser
1190+
- `block_until_callback` (default: `True`): Whether to block until callback received
11881191

11891192
**StarletteAuthCoordinator** (Web/Serverless):
11901193
- Delegates to remote callback endpoint (Starlette/FastAPI)

packages/mcp/src/keycardai/mcp/client/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,70 @@ def main():
269269
asyncio.run(run())
270270
```
271271

272+
#### Manual Browser Control (Non-Blocking)
273+
274+
If you prefer to control when the browser opens or want a non-blocking flow:
275+
276+
```python
277+
import asyncio
278+
from keycardai.mcp.client import Client, LocalAuthCoordinator, InMemoryBackend
279+
280+
servers = {
281+
"my-server": {
282+
"url": "http://localhost:7878/mcp",
283+
"transport": "http",
284+
"auth": {"type": "oauth"}
285+
}
286+
}
287+
288+
async def run():
289+
# Disable auto-open browser and blocking behavior
290+
coordinator = LocalAuthCoordinator(
291+
backend=InMemoryBackend(),
292+
host="localhost",
293+
port=8888,
294+
callback_path="/oauth/callback",
295+
auto_open_browser=False, # Don't auto-open browser
296+
block_until_callback=False # Return immediately instead of blocking
297+
)
298+
299+
async with Client(servers, auth_coordinator=coordinator) as client:
300+
# Try to connect (non-blocking if auth needed)
301+
await client.connect()
302+
303+
# Check if authentication is required
304+
auth_status = await coordinator.get_auth_pending(
305+
context_id=client.context.id,
306+
server_name="my-server"
307+
)
308+
309+
if auth_status:
310+
# Auth URL is logged but not auto-opened
311+
auth_url = auth_status.get("authorization_url")
312+
print(f"\n🔐 Authentication required!")
313+
print(f"Please visit: {auth_url}\n")
314+
315+
# Wait for user to complete auth in browser
316+
# (callback server still runs in background)
317+
import time
318+
while auth_status:
319+
await asyncio.sleep(1)
320+
auth_status = await coordinator.get_auth_pending(
321+
context_id=client.context.id,
322+
server_name="my-server"
323+
)
324+
325+
# Reconnect now that auth is complete
326+
await client.connect()
327+
328+
# Now authenticated - use the tools
329+
tools = await client.list_tools("my-server")
330+
print(f"Available tools: {len(tools)}")
331+
332+
def main():
333+
asyncio.run(run())
334+
```
335+
272336
---
273337

274338
### 2. Web Applications

packages/mcp/src/keycardai/mcp/client/auth/coordinators/endpoint_managers.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ class LocalEndpointManager(EndpointManager):
5252
5353
Key behaviors:
5454
- Starts local HTTP server on demand (lazy initialization)
55-
- Opens user's browser to authorization URL
56-
- BLOCKS in initiate_redirect() until callback is received
55+
- Opens user's browser to authorization URL (configurable)
56+
- BLOCKS in initiate_redirect() until callback is received (configurable)
5757
- Tracks pending flows by OAuth state parameter
5858
5959
Use for: CLI apps, desktop apps, local development.
@@ -63,7 +63,9 @@ def __init__(
6363
self,
6464
host: str = "localhost",
6565
port: int = 0,
66-
callback_path: str = "/callback"
66+
callback_path: str = "/callback",
67+
auto_open_browser: bool = True,
68+
block_until_callback: bool = True
6769
):
6870
"""
6971
Initialize local endpoint manager.
@@ -72,11 +74,15 @@ def __init__(
7274
host: Host for local server (default: localhost)
7375
port: Port for local server (0 = auto-assign)
7476
callback_path: Path for callback endpoint (default: /callback)
77+
auto_open_browser: Whether to automatically open browser (default: True)
78+
block_until_callback: Whether to block until callback received (default: True)
7579
"""
7680
self._host = host
7781
self._desired_port = port
7882
self._actual_port: int | None = None
7983
self._callback_path = callback_path
84+
self._auto_open_browser = auto_open_browser
85+
self._block_until_callback = block_until_callback
8086

8187
self._server_task: asyncio.Task | None = None
8288
self._ready_event = asyncio.Event()
@@ -125,21 +131,22 @@ async def get_redirect_uris(self) -> list[str]:
125131

126132
async def initiate_redirect(self, url: str, metadata: dict[str, Any]) -> None:
127133
"""
128-
Open browser and BLOCK until callback is received.
134+
Initiate OAuth redirect (configurable browser opening and blocking).
129135
130-
This is the key behavior for CLI apps: the function blocks until
131-
the user completes authorization in their browser.
136+
Behavior depends on configuration:
137+
- auto_open_browser=True: Opens browser automatically
138+
- auto_open_browser=False: Logs URL for manual opening
139+
- block_until_callback=True: Blocks until callback received
140+
- block_until_callback=False: Returns immediately
132141
133142
Args:
134143
url: Authorization URL to open in browser
135144
metadata: Flow metadata including server_name
136145
137146
Raises:
138-
TimeoutError: If authorization not completed within 300 seconds
147+
TimeoutError: If authorization not completed within 300 seconds (when blocking)
139148
"""
140149
server_name = metadata.get("server_name", "unknown")
141-
logger.info(f"Opening browser for auth flow (server: {server_name})")
142-
logger.info("Please authorize in your browser...")
143150

144151
state = self._extract_state_from_url(url)
145152
if not state:
@@ -149,16 +156,25 @@ async def initiate_redirect(self, url: str, metadata: dict[str, Any]) -> None:
149156
if state not in self._pending_flows:
150157
self._pending_flows[state] = asyncio.Event()
151158

152-
webbrowser.open(url)
159+
if self._auto_open_browser:
160+
logger.info(f"Opening browser for auth flow (server: {server_name})")
161+
logger.info("Please authorize in your browser...")
162+
webbrowser.open(url)
163+
else:
164+
logger.info(f"Authorization required for {server_name}")
165+
logger.info(f"Please visit: {url}")
153166

154-
logger.info("Waiting for authorization to complete...")
155-
try:
156-
await asyncio.wait_for(self._pending_flows[state].wait(), timeout=300)
157-
logger.info(f"Authorization completed for {server_name}")
158-
except asyncio.TimeoutError as e:
159-
logger.error("Authorization timed out after 300s")
160-
self._pending_flows.pop(state, None)
161-
raise TimeoutError(f"Authorization timed out for {server_name}") from e
167+
if self._block_until_callback:
168+
logger.info("Waiting for authorization to complete...")
169+
try:
170+
await asyncio.wait_for(self._pending_flows[state].wait(), timeout=300)
171+
logger.info(f"Authorization completed for {server_name}")
172+
except asyncio.TimeoutError as e:
173+
logger.error("Authorization timed out after 300s")
174+
self._pending_flows.pop(state, None)
175+
raise TimeoutError(f"Authorization timed out for {server_name}") from e
176+
else:
177+
logger.debug(f"Non-blocking mode: returning immediately for {server_name}")
162178

163179
async def shutdown(self) -> None:
164180
"""

packages/mcp/src/keycardai/mcp/client/auth/coordinators/local.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,19 @@ class LocalAuthCoordinator(AuthCoordinator):
1919
Use for: CLI apps, desktop apps, local development.
2020
2121
Key behaviors:
22-
- Opens browser to authorization URL
23-
- Blocks in handle_redirect() until completion arrives
24-
- Requires synchronous cleanup to avoid race conditions
22+
- Opens browser to authorization URL (configurable)
23+
- Blocks in handle_redirect() until completion arrives (configurable)
24+
- Requires synchronous cleanup to avoid race conditions (when blocking)
2525
"""
2626

2727
def __init__(
2828
self,
2929
backend: StorageBackend | None = None,
3030
host: str = "localhost",
3131
port: int = 0,
32-
callback_path: str = "/callback"
32+
callback_path: str = "/callback",
33+
auto_open_browser: bool = True,
34+
block_until_callback: bool = True
3335
):
3436
"""
3537
Initialize local coordinator with LocalEndpointManager.
@@ -39,9 +41,17 @@ def __init__(
3941
host: Host for local server (default: localhost)
4042
port: Port (0 = auto-assign)
4143
callback_path: HTTP callback path for OAuth redirects (default: /callback)
44+
auto_open_browser: Whether to automatically open browser (default: True)
45+
block_until_callback: Whether to block until callback received (default: True)
4246
"""
43-
# Create endpoint manager
44-
endpoint_manager = LocalEndpointManager(host, port, callback_path)
47+
# Create endpoint manager with configurable behavior
48+
endpoint_manager = LocalEndpointManager(
49+
host,
50+
port,
51+
callback_path,
52+
auto_open_browser=auto_open_browser,
53+
block_until_callback=block_until_callback
54+
)
4555

4656
# Initialize base coordinator with endpoint manager
4757
super().__init__(backend, endpoint_manager)

0 commit comments

Comments
 (0)