dreadnode 2.0.26__py3-none-any.whl → 2.0.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. dreadnode/agents/mcp/auth.py +348 -11
  2. dreadnode/agents/mcp/client.py +135 -12
  3. dreadnode/app/api/client.py +6 -2
  4. dreadnode/app/api/models.py +23 -1
  5. dreadnode/app/cli/task.py +13 -1
  6. dreadnode/app/client/runtime_client.py +21 -0
  7. dreadnode/app/main.py +26 -1
  8. dreadnode/app/print_mode.py +7 -0
  9. dreadnode/app/server/app.py +79 -3
  10. dreadnode/app/server/capability_manager.py +415 -117
  11. dreadnode/app/tui/app.py +115 -16
  12. dreadnode/app/tui/capabilities_manager.py +17 -4
  13. dreadnode/app/tui/dreadnode.tcss +20 -2
  14. dreadnode/app/tui/model_manager.py +34 -1
  15. dreadnode/app/tui/screen_router.py +8 -2
  16. dreadnode/app/tui/screens/capabilities.py +45 -0
  17. dreadnode/app/tui/screens/models.py +145 -29
  18. dreadnode/app/tui/screens/services.py +576 -199
  19. dreadnode/app/tui/widgets/context_bar.py +21 -2
  20. dreadnode/app/tui/widgets/help_panel.py +16 -0
  21. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/INDEX.md +0 -1
  22. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/mcp-servers.md +105 -2
  23. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/writing-skills.md +0 -2
  24. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/agents.md +3 -1
  25. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/capabilities.md +30 -0
  26. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/task.md +2 -0
  27. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/overview.md +2 -1
  28. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/quickstart.md +102 -67
  29. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/credits.md +8 -0
  30. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/sandboxes/environment-lifecycle.md +0 -1
  31. dreadnode/capabilities/loader.py +61 -1
  32. dreadnode/capabilities/types.py +77 -2
  33. dreadnode/generators/generator/litellm_.py +9 -0
  34. dreadnode/packaging/oci.py +23 -3
  35. dreadnode/packaging/task_validation.py +141 -26
  36. dreadnode/training/jobs.py +8 -2
  37. {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/METADATA +1 -1
  38. {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/RECORD +41 -42
  39. {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/WHEEL +1 -1
  40. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/guides/capability-optimization-loop.md +0 -133
  41. {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/entry_points.txt +0 -0
  42. {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,26 @@
1
1
  """
2
2
  MCP OAuth authentication support.
3
3
 
4
- Provides file-based token storage and helpers for building OAuth providers
5
- using the MCP SDK's built-in OAuthClientProvider.
4
+ Provides file-based token storage, a localhost callback server for catching
5
+ OAuth redirects, a browser-opening redirect handler, and a factory that wires
6
+ those defaults onto the MCP SDK's :class:`OAuthClientProvider`.
7
+
8
+ Together they enable native HTTP MCP servers (e.g. Linear, Atlassian) to be
9
+ authenticated end-to-end by the Dreadnode runtime without going through the
10
+ ``npx mcp-remote`` stdio bridge.
6
11
  """
7
12
 
8
13
  import asyncio
14
+ import contextlib
9
15
  import json
10
16
  import os
17
+ import socket
18
+ import sys
11
19
  import typing as t
20
+ import webbrowser
12
21
  from collections.abc import Awaitable, Callable
13
22
  from pathlib import Path
23
+ from urllib.parse import parse_qs, urlparse
14
24
 
15
25
  from loguru import logger
16
26
 
@@ -22,6 +32,21 @@ from dreadnode.agents.mcp.config import OAuthConfig
22
32
 
23
33
  DEFAULT_AUTH_PATH = Path.home() / ".dreadnode" / "mcp-auth.json"
24
34
 
35
+ # Default time (seconds) the local callback server waits for the user to
36
+ # complete the OAuth flow in their browser before giving up.
37
+ _DEFAULT_CALLBACK_TIMEOUT = 300.0
38
+
39
+
40
+ class MCPOAuthRequiredError(Exception):
41
+ """Raised when a connect would need to open a browser to complete OAuth
42
+ but the runtime isn't allowed to (a non-interactive / background connect).
43
+
44
+ Signals the lifecycle to classify the server as ``needs_auth`` and defer
45
+ the browser-open to a user-initiated Authenticate, rather than popping a
46
+ window during startup (CAP-MCP-010 — the runtime owns the browser-open
47
+ moment).
48
+ """
49
+
25
50
 
26
51
  class FileTokenStorage:
27
52
  """Persist OAuth tokens to disk, keyed by server URL.
@@ -57,6 +82,17 @@ class FileTokenStorage:
57
82
  def _get_entry(self) -> dict[str, t.Any]:
58
83
  return self._read_store().get(self._server_url, {})
59
84
 
85
+ def has_tokens(self) -> bool:
86
+ """Synchronously report whether this server has stored OAuth tokens.
87
+
88
+ Used by the runtime to decide, *before* a background connect, whether
89
+ to wire up an OAuth provider at all. With no stored token a background
90
+ connect attaches no provider — a 401 then surfaces as ``needs_auth``
91
+ without any discovery/DCR traffic or browser-open. A small local file
92
+ read is cheap enough to do inline on the connect path.
93
+ """
94
+ return bool(self._get_entry().get("tokens"))
95
+
60
96
  def _set_entry(self, entry: dict[str, t.Any]) -> None:
61
97
  store = self._read_store()
62
98
  store[self._server_url] = entry
@@ -104,42 +140,343 @@ class FileTokenStorage:
104
140
 
105
141
  await asyncio.to_thread(_update)
106
142
 
143
+ async def clear(self) -> None:
144
+ """Remove this server's stored tokens + client_info from the file.
145
+
146
+ Targeted by server URL — other servers' entries are untouched.
147
+ Used by the user-initiated re-authenticate flow so a fresh OAuth
148
+ round-trip runs on the next connect without wiping the whole
149
+ cache (other authenticated capabilities keep working).
150
+ """
151
+
152
+ def _clear() -> None:
153
+ store = self._read_store()
154
+ if self._server_url in store:
155
+ del store[self._server_url]
156
+ self._write_store(store)
157
+
158
+ await asyncio.to_thread(_clear)
159
+
160
+
161
+ # --- Localhost OAuth callback server -----------------------------------------
162
+
163
+
164
+ _SUCCESS_HTML = (
165
+ b"<!doctype html><html><head><meta charset='utf-8'>"
166
+ b"<title>Dreadnode \xe2\x80\x94 Authentication complete</title>"
167
+ b"<style>body{font:14px/1.5 system-ui,sans-serif;max-width:32rem;"
168
+ b"margin:6rem auto;padding:0 1rem;color:#222;}h2{margin-bottom:.5rem}</style>"
169
+ b"</head><body><h2>Authentication complete \xe2\x9c\x93</h2>"
170
+ b"<p>You can close this tab and return to the Dreadnode TUI.</p>"
171
+ b"</body></html>"
172
+ )
173
+
174
+ _ERROR_HTML = (
175
+ b"<!doctype html><html><head><meta charset='utf-8'>"
176
+ b"<title>Dreadnode \xe2\x80\x94 Authentication failed</title></head>"
177
+ b"<body><h2>Authentication failed</h2>"
178
+ b"<p>The authorization server didn't return a code parameter. "
179
+ b"Return to the Dreadnode TUI and retry.</p></body></html>"
180
+ )
181
+
182
+
183
+ class LocalCallbackServer:
184
+ """One-shot HTTP server on a 127.0.0.1 ephemeral port for OAuth redirects.
185
+
186
+ Binds a socket eagerly in ``__init__`` so ``redirect_uri`` is known
187
+ before the OAuth flow needs it (the authorization server has to be
188
+ told the redirect URI at the start of the authorization request, well
189
+ before the user actually completes the flow). ``start()`` converts
190
+ the bound socket into a running ``asyncio.Server``; ``wait_for_callback()``
191
+ blocks until the redirect arrives, then tears the server down.
192
+
193
+ Single-use by design — a fresh instance per OAuth flow, no recycling.
194
+ The redirect URI is always ``http://127.0.0.1:<port>/callback``.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ *,
200
+ host: str = "127.0.0.1",
201
+ timeout: float = _DEFAULT_CALLBACK_TIMEOUT,
202
+ ) -> None:
203
+ self._host = host
204
+ self._timeout = timeout
205
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
206
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
207
+ sock.bind((host, 0))
208
+ self._socket: socket.socket | None = sock
209
+ self._port: int = sock.getsockname()[1]
210
+ self._result: tuple[str, str | None] | None = None
211
+ self._error: str | None = None
212
+ self._received = asyncio.Event()
213
+ self._server: asyncio.Server | None = None
214
+
215
+ @property
216
+ def port(self) -> int:
217
+ return self._port
218
+
219
+ @property
220
+ def redirect_uri(self) -> str:
221
+ return f"http://{self._host}:{self._port}/callback"
222
+
223
+ async def start(self) -> None:
224
+ """Begin listening for the OAuth callback. Idempotent."""
225
+ if self._server is not None:
226
+ return
227
+ if self._socket is None:
228
+ msg = "LocalCallbackServer socket is already released — cannot start"
229
+ raise RuntimeError(msg)
230
+ self._server = await asyncio.start_server(self._handle_request, sock=self._socket)
231
+ # Server now owns the socket.
232
+ self._socket = None
233
+ logger.debug("OAuth callback server listening on {}", self.redirect_uri)
234
+
235
+ async def _handle_request(
236
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
237
+ ) -> None:
238
+ try:
239
+ request_line = await reader.readline()
240
+ line = request_line.decode("latin-1", errors="replace").strip()
241
+ # Drain headers — we only need the request line.
242
+ while True:
243
+ hdr = await reader.readline()
244
+ if not hdr or hdr in (b"\r\n", b"\n"):
245
+ break
246
+
247
+ _method, _, rest = line.partition(" ")
248
+ target, _, _ = rest.partition(" ")
249
+ parsed = urlparse(target)
250
+ params = parse_qs(parsed.query)
251
+ code = params.get("code", [""])[0]
252
+ state_list = params.get("state")
253
+ state = state_list[0] if state_list else None
254
+ err = params.get("error", [""])[0]
255
+ err_desc = params.get("error_description", [""])[0]
256
+
257
+ if code:
258
+ self._result = (code, state)
259
+ self._received.set()
260
+ self._respond(writer, b"200 OK", _SUCCESS_HTML)
261
+ else:
262
+ self._error = err_desc or err or "missing 'code' parameter"
263
+ logger.warning("OAuth callback returned error: {}", self._error)
264
+ self._respond(writer, b"400 Bad Request", _ERROR_HTML)
265
+ self._received.set()
266
+ except Exception as exc:
267
+ logger.warning("OAuth callback handler error: {}", exc)
268
+ self._error = f"callback handler error: {exc}"
269
+ self._received.set()
270
+ finally:
271
+ with contextlib.suppress(Exception):
272
+ writer.close()
273
+ await writer.wait_closed()
274
+
275
+ @staticmethod
276
+ def _respond(writer: asyncio.StreamWriter, status: bytes, body: bytes) -> None:
277
+ writer.write(b"HTTP/1.1 " + status + b"\r\n")
278
+ writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
279
+ writer.write(f"Content-Length: {len(body)}\r\n".encode())
280
+ writer.write(b"Connection: close\r\n\r\n")
281
+ writer.write(body)
282
+
283
+ async def wait_for_callback(self) -> tuple[str, str | None]:
284
+ """Block until the OAuth callback arrives. Returns (code, state).
285
+
286
+ Raises ``TimeoutError`` if the user doesn't complete the flow
287
+ within the configured timeout, or ``RuntimeError`` if the
288
+ callback arrived with an error parameter instead of a code.
289
+ Always tears the server down before returning.
290
+ """
291
+ try:
292
+ await asyncio.wait_for(self._received.wait(), timeout=self._timeout)
293
+ finally:
294
+ await self.aclose()
295
+ if self._result is not None:
296
+ return self._result
297
+ err = self._error or "no callback received"
298
+ raise RuntimeError(f"OAuth callback failed: {err}")
299
+
300
+ async def aclose(self) -> None:
301
+ """Shut down the server and release the socket. Idempotent."""
302
+ if self._server is not None:
303
+ self._server.close()
304
+ with contextlib.suppress(Exception):
305
+ await self._server.wait_closed()
306
+ self._server = None
307
+ if self._socket is not None:
308
+ with contextlib.suppress(Exception):
309
+ self._socket.close()
310
+ self._socket = None
311
+
312
+ def __del__(self) -> None: # pragma: no cover - best-effort GC cleanup
313
+ # Close the held socket if start() was never called (e.g. the
314
+ # OAuth flow never actually needed auth because a cached token
315
+ # was used). asyncio.Server cleanup is async and not reachable
316
+ # from __del__; rely on the loop's own teardown there.
317
+ if self._socket is not None:
318
+ with contextlib.suppress(Exception):
319
+ self._socket.close()
320
+
321
+
322
+ # --- Redirect handler --------------------------------------------------------
323
+
324
+
325
+ def _is_headless() -> bool:
326
+ """Best-effort detection of environments where opening a browser fails.
327
+
328
+ Honors the ``DREADNODE_HEADLESS`` opt-out for users who'd prefer to
329
+ complete OAuth manually even on a desktop machine. On Linux,
330
+ absence of ``DISPLAY`` and ``WAYLAND_DISPLAY`` is taken as a strong
331
+ signal (SSH, container, headless CI). On macOS/Windows we trust
332
+ ``webbrowser.open``'s return value instead.
333
+ """
334
+ if os.environ.get("DREADNODE_HEADLESS", "").strip().lower() in ("1", "true", "yes", "on"):
335
+ return True
336
+ if sys.platform.startswith("linux"):
337
+ if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
338
+ return True
339
+ return False
340
+
341
+
342
+ async def _browser_open_redirect_handler(url: str) -> None:
343
+ """Open the user's browser to *url*, or log it prominently if we can't.
344
+
345
+ The localhost callback server is the user's only way to deliver the
346
+ OAuth code back to us, so this handler must succeed *or* surface a
347
+ very clear manual fallback. ``DREADNODE_HEADLESS=1`` forces the
348
+ fallback path even on a desktop, which is useful for SSH sessions
349
+ that forward a port but no display.
350
+ """
351
+ if _is_headless():
352
+ logger.warning(
353
+ "MCP OAuth: browser disabled (DREADNODE_HEADLESS or no display). "
354
+ "Visit this URL to authorize:\n {}",
355
+ url,
356
+ )
357
+ return
358
+
359
+ try:
360
+ opened = webbrowser.open(url, new=1, autoraise=True)
361
+ except Exception as exc:
362
+ logger.debug("webbrowser.open raised: {}", exc)
363
+ opened = False
364
+
365
+ if opened:
366
+ logger.info("MCP OAuth: opened browser for authorization. Complete the flow to continue.")
367
+ else:
368
+ logger.warning(
369
+ "MCP OAuth: could not open a browser. Visit this URL manually:\n {}",
370
+ url,
371
+ )
372
+
107
373
 
108
374
  async def _default_redirect_handler(url: str) -> None:
375
+ """Backwards-compat shim — log-only handler, kept for external callers
376
+ that explicitly pass it. New defaults use ``_browser_open_redirect_handler``.
377
+ """
109
378
  logger.info("MCP OAuth: Visit this URL to authorize:\n {}", url)
110
379
 
111
380
 
381
+ async def _deferred_redirect_handler(url: str) -> None:
382
+ """Non-interactive redirect handler: refuse to open a browser.
383
+
384
+ Used by background connects (CAP-MCP-010). When the OAuth flow reaches
385
+ the point of opening the authorization URL, we raise instead — the
386
+ lifecycle classifies the server ``needs_auth`` and the user opens the
387
+ browser later via an explicit Authenticate. The URL is logged at debug
388
+ so it's recoverable for diagnostics, never auto-opened.
389
+ """
390
+ logger.debug("MCP OAuth: authorization required (deferred, non-interactive): {}", url)
391
+ raise MCPOAuthRequiredError(f"OAuth authorization required for {url}")
392
+
393
+
394
+ async def _deferred_callback_handler() -> tuple[str, str | None]:
395
+ """Non-interactive callback handler — never reached.
396
+
397
+ The deferred redirect handler raises before any callback is awaited;
398
+ this exists only to satisfy the provider's handler-pair contract.
399
+ """
400
+ raise MCPOAuthRequiredError("OAuth authorization required (non-interactive)")
401
+
402
+
112
403
  def create_oauth_provider(
113
404
  server_url: str,
114
405
  config: OAuthConfig | None = None,
115
406
  storage: "TokenStorage | None" = None,
116
407
  redirect_handler: Callable[[str], Awaitable[None]] | None = None,
117
408
  callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
409
+ *,
410
+ interactive: bool = True,
411
+ callback_server: LocalCallbackServer | None = None,
118
412
  ) -> "OAuthClientProvider":
119
- """Build an OAuthClientProvider from our config types.
413
+ """Build an :class:`OAuthClientProvider` wired with working defaults.
414
+
415
+ If neither ``redirect_handler`` nor ``callback_handler`` is provided,
416
+ the function wires the pair according to ``interactive``:
417
+
418
+ - ``interactive=True`` (user-initiated): spin up a
419
+ :class:`LocalCallbackServer`, open the user's browser (falling back to
420
+ a logged URL when headless), and block on the callback server until the
421
+ redirect arrives. The OAuth flow completes end-to-end.
422
+ - ``interactive=False`` (background/startup): never open a browser. A
423
+ valid stored token is still used/refreshed transparently; if the flow
424
+ would actually need to authorize, the deferred redirect handler raises
425
+ :class:`MCPOAuthRequiredError` so the runtime classifies ``needs_auth`` and
426
+ defers the browser to a user-initiated Authenticate (CAP-MCP-010).
427
+
428
+ Passing a custom ``redirect_handler`` *and* ``callback_handler``
429
+ bypasses the defaults entirely — useful for custom UIs (in-TUI
430
+ overlay, IDE picker, etc.).
120
431
 
121
432
  Args:
122
433
  server_url: The MCP server URL.
123
434
  config: OAuth configuration. Uses defaults if None.
124
- storage: Token storage. Defaults to FileTokenStorage.
125
- redirect_handler: Called with auth URL. Defaults to logging the URL.
126
- callback_handler: Called to get (code, state). None by default
127
- (Layer 3/TUI provides this for interactive flows).
435
+ storage: Token storage. Defaults to :class:`FileTokenStorage`.
436
+ redirect_handler: Called with the authorization URL. Defaults per
437
+ ``interactive`` (browser-open, or deferred raise).
438
+ callback_handler: Called to retrieve ``(code, state)`` from the
439
+ redirect. Defaults to blocking on a localhost callback server.
440
+ interactive: Whether this connect may open a browser. Defaults True.
441
+ callback_server: Inject a pre-built callback server (testing).
128
442
  """
129
443
  from mcp.client.auth.oauth2 import OAuthClientProvider
130
444
  from mcp.shared.auth import OAuthClientMetadata
131
445
 
132
446
  config = config or OAuthConfig()
133
447
 
448
+ if storage is None:
449
+ storage = FileTokenStorage(server_url)
450
+
451
+ # If the caller didn't fully customize the handlers, set up the default
452
+ # pair. We do this even when only one side is overridden — that path is
453
+ # symmetric, both halves must come from the caller.
454
+ redirect_uris: list[t.Any] | None = None
455
+ if redirect_handler is None and callback_handler is None:
456
+ if not interactive:
457
+ # Background connect: stored tokens still work, but never pop a
458
+ # browser. The deferred handler raises if authorization is needed.
459
+ redirect_handler = _deferred_redirect_handler
460
+ callback_handler = _deferred_callback_handler
461
+ else:
462
+ if callback_server is None:
463
+ callback_server = LocalCallbackServer()
464
+ captured_server = callback_server # bind for closures + type narrowing
465
+
466
+ async def _redirect(url: str) -> None:
467
+ await captured_server.start()
468
+ await _browser_open_redirect_handler(url)
469
+
470
+ redirect_handler = _redirect
471
+ callback_handler = captured_server.wait_for_callback
472
+ redirect_uris = [captured_server.redirect_uri]
473
+
134
474
  client_metadata = OAuthClientMetadata(
135
- redirect_uris=None,
475
+ redirect_uris=redirect_uris,
136
476
  client_name=config.client_name,
137
477
  scope=config.scope,
138
478
  )
139
479
 
140
- if storage is None:
141
- storage = FileTokenStorage(server_url)
142
-
143
480
  return OAuthClientProvider(
144
481
  server_url=server_url,
145
482
  client_metadata=client_metadata,
@@ -39,6 +39,13 @@ if t.TYPE_CHECKING:
39
39
  from dreadnode.generators.message import Content
40
40
 
41
41
 
42
+ # An interactive (user-initiated) OAuth connect blocks on a human browser
43
+ # round-trip, so the per-step init timeout must comfortably exceed the
44
+ # localhost callback server's wait (_DEFAULT_CALLBACK_TIMEOUT = 300s).
45
+ # Background connects keep the normal, tight init timeout.
46
+ _INTERACTIVE_AUTH_INIT_TIMEOUT = 330.0
47
+
48
+
42
49
  class _StderrCapture:
43
50
  """Async reader that buffers the last N lines from a pipe fd.
44
51
 
@@ -221,6 +228,12 @@ class MCPClient:
221
228
  self._exit_stack = AsyncExitStack()
222
229
  self._session = None
223
230
  self._oauth_config = oauth
231
+ # Whether the in-flight connect may open a browser for OAuth. Set by
232
+ # connect(); defaults to the conservative non-interactive mode so a
233
+ # browser only opens when a caller explicitly opts in (the runtime does
234
+ # this on a user-initiated reconnect) — not from a background/startup
235
+ # connect (CAP-MCP-010: the runtime owns the browser-open moment).
236
+ self._auth_interactive = False
224
237
  self._init_timeout = init_timeout
225
238
  # Only meaningful for stdio transports — the stderr capture wires it
226
239
  # into _StderrCapture on connect. HTTP transports ignore it.
@@ -342,15 +355,54 @@ class MCPClient:
342
355
  return cause
343
356
 
344
357
  def _set_error_status(self, exc: BaseException) -> BaseException:
358
+ from dreadnode.agents.mcp.auth import MCPOAuthRequiredError
359
+
345
360
  cause = self._unwrap_exception(exc)
346
361
  error_msg = str(cause)
347
362
 
363
+ # A background connect that hit the deferred OAuth handler: the server
364
+ # needs fresh authorization but we declined to open a browser. This is
365
+ # needs_auth, not a failure — the user authenticates explicitly later.
366
+ if isinstance(cause, MCPOAuthRequiredError):
367
+ self._status = MCPStatus.NEEDS_AUTH
368
+ self._error = error_msg
369
+ return cause
370
+
371
+ # The user-facing error report keeps the last 10 lines of stderr
372
+ # so operators can see why the server died at a glance.
348
373
  if self._stderr_capture and self._stderr_capture.last_lines:
349
374
  stderr_tail = "\n".join(self._stderr_capture.last_lines[-10:])
350
375
  error_msg = f"{error_msg}\n\nServer stderr:\n{stderr_tail}"
351
376
 
352
- error_lower = error_msg.lower()
353
- if any(kw in error_lower for kw in ("unauthorized", "forbidden", "401", "403", "oauth")):
377
+ # Auth classification scans the *full* captured buffer (capped at
378
+ # 50 lines by _StderrCapture), not just the report tail. Reason:
379
+ # mcp-remote prints "Please authorize…" / "Authentication required"
380
+ # *once*, near the start, then polls a `/wait-for-auth` callback
381
+ # every couple of seconds. By the time the lifecycle hits its
382
+ # init_timeout (60s) and we classify, those early markers have
383
+ # been pushed out of the last-10 window — but they're still in
384
+ # the 50-line buffer. Matching the full buffer makes the
385
+ # classification robust to polling chatter (ENG-6989).
386
+ scan_text = error_msg
387
+ if self._stderr_capture and self._stderr_capture.last_lines:
388
+ scan_text = "\n".join([str(cause), *self._stderr_capture.last_lines])
389
+
390
+ scan_lower = scan_text.lower()
391
+ auth_keywords = (
392
+ "unauthorized",
393
+ "forbidden",
394
+ "401",
395
+ "403",
396
+ "oauth",
397
+ "please authorize",
398
+ "authorize this client",
399
+ # mcp-remote-specific markers that survive the buffer even
400
+ # after the bridge polls /wait-for-auth for 60s.
401
+ "authentication required",
402
+ "waiting for authentication",
403
+ "wait-for-auth",
404
+ )
405
+ if any(kw in scan_lower for kw in auth_keywords):
354
406
  self._status = MCPStatus.NEEDS_AUTH
355
407
  else:
356
408
  self._status = MCPStatus.FAILED
@@ -382,8 +434,15 @@ class MCPClient:
382
434
  )
383
435
  raise TypeError(msg) # noqa: TRY301
384
436
 
385
- await asyncio.wait_for(self.session.initialize(), timeout=self._init_timeout)
386
- await asyncio.wait_for(self._load_tools(), timeout=self._init_timeout)
437
+ # An interactive OAuth connect blocks on a human browser round-trip
438
+ # during initialize(), so widen the timeout for that case only.
439
+ init_timeout = (
440
+ max(self._init_timeout, _INTERACTIVE_AUTH_INIT_TIMEOUT)
441
+ if self._auth_interactive and self.transport == "streamable-http"
442
+ else self._init_timeout
443
+ )
444
+ await asyncio.wait_for(self.session.initialize(), timeout=init_timeout)
445
+ await asyncio.wait_for(self._load_tools(), timeout=init_timeout)
387
446
 
388
447
  self._status = MCPStatus.CONNECTED
389
448
  self._error = None
@@ -455,6 +514,8 @@ class MCPClient:
455
514
  headers: dict[str, str] | None,
456
515
  http_timeout: float,
457
516
  auth: t.Any,
517
+ *,
518
+ allow_auth_challenge: bool = False,
458
519
  ) -> bool:
459
520
  """Probe whether a URL supports streamable HTTP (POST).
460
521
 
@@ -462,6 +523,11 @@ class MCPClient:
462
523
  clearly doesn't. Connectivity errors also return False (server
463
524
  unreachable via POST, try SSE). Auth and SSL errors are allowed to
464
525
  propagate — they would affect SSE equally.
526
+
527
+ When ``allow_auth_challenge`` is set (an OAuth provider is attached to
528
+ the real connect), a 401/403 is treated as a streamable-http server
529
+ that simply needs authentication: the probe returns True so the
530
+ authenticated connect runs, instead of raising or routing to SSE.
465
531
  """
466
532
  try:
467
533
  probe_headers = dict(headers) if headers else {}
@@ -473,9 +539,19 @@ class MCPClient:
473
539
  )
474
540
  async with httpx.AsyncClient(timeout=min(http_timeout, 5), auth=auth) as probe:
475
541
  r = await probe.post(url, content=b"{}", headers=probe_headers)
476
- # Auth errors affect both transports equally propagate so
542
+ # Auth errors affect both transports equally. With a provider
543
+ # attached the authenticated connect will handle the challenge,
544
+ # so treat the server as streamable-http; otherwise propagate so
477
545
  # callers can classify as NEEDS_AUTH instead of falling through.
478
546
  if r.status_code in (401, 403):
547
+ if allow_auth_challenge:
548
+ logger.debug(
549
+ "Streamable HTTP probe got {} for {}, deferring to "
550
+ "authenticated connect",
551
+ r.status_code,
552
+ url,
553
+ )
554
+ return True
479
555
  r.raise_for_status()
480
556
  if r.status_code == 400 and self._looks_like_jsonrpc_probe_rejection(r):
481
557
  logger.debug(
@@ -535,7 +611,19 @@ class MCPClient:
535
611
  # upstream MCP SDK's streamable-http transport crashes during cleanup
536
612
  # with an uncatchable BaseExceptionGroup. Skip straight to SSE when
537
613
  # the server clearly doesn't accept POST.
538
- if not await self._probe_supports_streamable_http(url, headers, timeout, auth):
614
+ #
615
+ # The probe always runs with ``auth=None`` so it stays cheap and never
616
+ # drags the interactive OAuth flow through the short-timeout probe
617
+ # client. When a provider is attached we still need the probe to tell
618
+ # an SSE-only server (4xx POST rejection → SSE) apart from an
619
+ # OAuth-protected streamable-http server (401/403 → proceed to the
620
+ # authenticated connect). ``allow_auth_challenge`` makes the probe
621
+ # treat a 401/403 as "streamable-http, just needs auth" instead of
622
+ # raising, so the authenticated connect runs and the provider handles
623
+ # the challenge.
624
+ if not await self._probe_supports_streamable_http(
625
+ url, headers, timeout, None, allow_auth_challenge=auth is not None
626
+ ):
539
627
  return await self._connect_via_sse_internal(connection)
540
628
 
541
629
  try:
@@ -563,30 +651,65 @@ class MCPClient:
563
651
  headers = connection.get("headers")
564
652
  timeout = connection.get("timeout", DEFAULT_HTTP_TIMEOUT)
565
653
  sse_read_timeout = connection.get("sse_read_timeout", DEFAULT_SSE_READ_TIMEOUT)
654
+ # Mirror the streamable-http path: forward the OAuth provider so an
655
+ # SSE-fallback connect to an authenticated server still completes
656
+ # the auth flow rather than 401'ing silently.
657
+ auth = self._build_auth_provider(url)
566
658
 
567
659
  ctx = sse_client(
568
660
  url=url,
569
661
  headers=headers,
570
662
  timeout=timeout,
571
663
  sse_read_timeout=sse_read_timeout,
664
+ auth=auth,
572
665
  )
573
666
  read, write = await self._exit_stack.enter_async_context(ctx)
574
667
  return await self._exit_stack.enter_async_context(ClientSession(read, write))
575
668
 
576
669
  def _build_auth_provider(self, url: str) -> t.Any:
577
- """Build an httpx.Auth provider if OAuth is configured."""
578
- if self._oauth_config is None:
579
- return None
670
+ """Build an httpx.Auth OAuth provider for a streamable-http server.
671
+
672
+ Reactive-by-default (CAP-MCP-011): every HTTP server gets a provider so
673
+ a 401 can drive RFC 9728 / RFC 8414 discovery + DCR + PKCE without the
674
+ manifest pre-declaring anything. The provider stays dormant unless the
675
+ server actually challenges with a 401 — it only adds an auth header
676
+ when it holds a valid token — so it's a no-op for public servers and
677
+ for servers using static header / API-key auth (their headers win).
678
+
679
+ A declared ``auth:`` block (``self._oauth_config``) is an optional
680
+ refinement carrying ``scope`` / ``client_name``; it's no longer a
681
+ precondition for OAuth.
682
+
683
+ In a background (non-interactive) connect we attach a provider only
684
+ when a token is already stored: with one it's used/refreshed silently;
685
+ without one we return ``None`` so a 401 surfaces as ``needs_auth`` with
686
+ no discovery traffic and no browser. The user then authenticates via
687
+ an interactive reconnect.
688
+ """
689
+ from dreadnode.agents.mcp.auth import FileTokenStorage, create_oauth_provider
690
+ from dreadnode.agents.mcp.config import OAuthConfig
580
691
 
581
- from dreadnode.agents.mcp.auth import create_oauth_provider
692
+ if not self._auth_interactive and not FileTokenStorage(url).has_tokens():
693
+ return None
582
694
 
583
- return create_oauth_provider(server_url=url, config=self._oauth_config)
695
+ config = self._oauth_config or OAuthConfig()
696
+ return create_oauth_provider(
697
+ server_url=url, config=config, interactive=self._auth_interactive
698
+ )
584
699
 
585
- async def connect(self) -> None:
700
+ async def connect(self, *, interactive: bool = False) -> None:
586
701
  """Connect to the MCP server and discover tools.
587
702
 
588
703
  Sets status to CONNECTED on success, FAILED or NEEDS_AUTH on error.
704
+
705
+ ``interactive`` controls OAuth behavior for streamable-http servers.
706
+ It defaults to False (the safe, non-interactive mode): a stored token
707
+ is still used/refreshed, but a server that needs fresh authorization
708
+ is left in ``needs_auth`` rather than opening a browser. Pass
709
+ ``interactive=True`` for a user-initiated connect (the runtime does
710
+ this on reconnect) to open the user's browser and complete OAuth.
589
711
  """
712
+ self._auth_interactive = interactive
590
713
  async with self._get_lifecycle_lock():
591
714
  if self._owner_task is not None and not self._owner_task.done():
592
715
  ready_future = self._ready_future
@@ -363,9 +363,13 @@ class ApiClient:
363
363
  response = self.request("GET", "/user/preferences", params=params)
364
364
  return t.cast("dict[str, t.Any]", response.json())
365
365
 
366
- def provision_inference_key(self, org_key: str) -> dict[str, t.Any]:
366
+ def provision_inference_key(self, org_key: str, client_id: str) -> dict[str, t.Any]:
367
367
  """POST /api/v1/org/{org}/inference/keys - Provision a litellm virtual key."""
368
- response = self.request("POST", f"/org/{org_key}/inference/keys")
368
+ response = self.request(
369
+ "POST",
370
+ f"/org/{org_key}/inference/keys",
371
+ json_data={"client_id": client_id},
372
+ )
369
373
  return t.cast("dict[str, t.Any]", response.json())
370
374
 
371
375
  # =========================================================================