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.
- dreadnode/agents/mcp/auth.py +348 -11
- dreadnode/agents/mcp/client.py +135 -12
- dreadnode/app/api/client.py +6 -2
- dreadnode/app/api/models.py +23 -1
- dreadnode/app/cli/task.py +13 -1
- dreadnode/app/client/runtime_client.py +21 -0
- dreadnode/app/main.py +26 -1
- dreadnode/app/print_mode.py +7 -0
- dreadnode/app/server/app.py +79 -3
- dreadnode/app/server/capability_manager.py +415 -117
- dreadnode/app/tui/app.py +115 -16
- dreadnode/app/tui/capabilities_manager.py +17 -4
- dreadnode/app/tui/dreadnode.tcss +20 -2
- dreadnode/app/tui/model_manager.py +34 -1
- dreadnode/app/tui/screen_router.py +8 -2
- dreadnode/app/tui/screens/capabilities.py +45 -0
- dreadnode/app/tui/screens/models.py +145 -29
- dreadnode/app/tui/screens/services.py +576 -199
- dreadnode/app/tui/widgets/context_bar.py +21 -2
- dreadnode/app/tui/widgets/help_panel.py +16 -0
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/INDEX.md +0 -1
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/mcp-servers.md +105 -2
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/writing-skills.md +0 -2
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/agents.md +3 -1
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/capabilities.md +30 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/task.md +2 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/overview.md +2 -1
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/quickstart.md +102 -67
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/credits.md +8 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/sandboxes/environment-lifecycle.md +0 -1
- dreadnode/capabilities/loader.py +61 -1
- dreadnode/capabilities/types.py +77 -2
- dreadnode/generators/generator/litellm_.py +9 -0
- dreadnode/packaging/oci.py +23 -3
- dreadnode/packaging/task_validation.py +141 -26
- dreadnode/training/jobs.py +8 -2
- {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/METADATA +1 -1
- {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/RECORD +41 -42
- {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/WHEEL +1 -1
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/guides/capability-optimization-loop.md +0 -133
- {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/entry_points.txt +0 -0
- {dreadnode-2.0.26.dist-info → dreadnode-2.0.28.dist-info}/licenses/LICENSE +0 -0
dreadnode/agents/mcp/auth.py
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
"""
|
|
2
2
|
MCP OAuth authentication support.
|
|
3
3
|
|
|
4
|
-
Provides file-based token storage
|
|
5
|
-
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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=
|
|
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,
|
dreadnode/agents/mcp/client.py
CHANGED
|
@@ -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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
692
|
+
if not self._auth_interactive and not FileTokenStorage(url).has_tokens():
|
|
693
|
+
return None
|
|
582
694
|
|
|
583
|
-
|
|
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
|
dreadnode/app/api/client.py
CHANGED
|
@@ -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(
|
|
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
|
# =========================================================================
|