fast-agent-mcp 0.3.12__py3-none-any.whl → 0.3.14__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.
Potentially problematic release.
This version of fast-agent-mcp might be problematic. Click here for more details.
- fast_agent/agents/llm_agent.py +15 -34
- fast_agent/agents/llm_decorator.py +13 -2
- fast_agent/agents/mcp_agent.py +18 -2
- fast_agent/agents/tool_agent.py +8 -10
- fast_agent/cli/commands/check_config.py +45 -1
- fast_agent/config.py +63 -0
- fast_agent/constants.py +3 -0
- fast_agent/context.py +42 -9
- fast_agent/core/logging/listeners.py +1 -1
- fast_agent/event_progress.py +2 -3
- fast_agent/interfaces.py +9 -2
- fast_agent/llm/model_factory.py +4 -0
- fast_agent/llm/provider/google/google_converter.py +10 -3
- fast_agent/llm/provider_key_manager.py +1 -0
- fast_agent/llm/provider_types.py +1 -0
- fast_agent/llm/request_params.py +3 -1
- fast_agent/mcp/mcp_agent_client_session.py +13 -0
- fast_agent/mcp/mcp_aggregator.py +313 -40
- fast_agent/mcp/mcp_connection_manager.py +95 -22
- fast_agent/mcp/skybridge.py +45 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/transport_tracking.py +37 -3
- fast_agent/mcp/types.py +24 -0
- fast_agent/resources/examples/workflows/router.py +1 -0
- fast_agent/resources/setup/fastagent.config.yaml +5 -0
- fast_agent/ui/console_display.py +347 -20
- fast_agent/ui/enhanced_prompt.py +107 -58
- fast_agent/ui/interactive_prompt.py +57 -34
- fast_agent/ui/mcp_display.py +159 -41
- fast_agent/ui/rich_progress.py +4 -1
- {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/METADATA +16 -7
- {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/RECORD +35 -32
- {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.12.dist-info → fast_agent_mcp-0.3.14.dist-info}/licenses/LICENSE +0 -0
|
@@ -17,7 +17,6 @@ from anyio import Event, Lock, create_task_group
|
|
|
17
17
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
18
18
|
from httpx import HTTPStatusError
|
|
19
19
|
from mcp import ClientSession
|
|
20
|
-
from mcp.client.sse import sse_client
|
|
21
20
|
from mcp.client.stdio import (
|
|
22
21
|
StdioServerParameters,
|
|
23
22
|
get_default_environment,
|
|
@@ -33,11 +32,14 @@ from fast_agent.event_progress import ProgressAction
|
|
|
33
32
|
from fast_agent.mcp.logger_textio import get_stderr_handler
|
|
34
33
|
from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
|
|
35
34
|
from fast_agent.mcp.oauth_client import build_oauth_provider
|
|
35
|
+
from fast_agent.mcp.sse_tracking import tracking_sse_client
|
|
36
36
|
from fast_agent.mcp.stdio_tracking_simple import tracking_stdio_client
|
|
37
37
|
from fast_agent.mcp.streamable_http_tracking import tracking_streamablehttp_client
|
|
38
38
|
from fast_agent.mcp.transport_tracking import TransportChannelMetrics
|
|
39
39
|
|
|
40
40
|
if TYPE_CHECKING:
|
|
41
|
+
from mcp.client.auth import OAuthClientProvider
|
|
42
|
+
|
|
41
43
|
from fast_agent.context import Context
|
|
42
44
|
from fast_agent.mcp_server_registry import ServerRegistry
|
|
43
45
|
|
|
@@ -65,6 +67,38 @@ def _add_none_to_context(context_manager):
|
|
|
65
67
|
return StreamingContextAdapter(context_manager)
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
def _prepare_headers_and_auth(
|
|
71
|
+
server_config: MCPServerSettings,
|
|
72
|
+
) -> tuple[dict[str, str], Optional["OAuthClientProvider"], set[str]]:
|
|
73
|
+
"""
|
|
74
|
+
Prepare request headers and determine if OAuth authentication should be used.
|
|
75
|
+
|
|
76
|
+
Returns a copy of the headers, an OAuth auth provider when applicable, and the set
|
|
77
|
+
of user-supplied authorization header keys.
|
|
78
|
+
"""
|
|
79
|
+
headers: dict[str, str] = dict(server_config.headers or {})
|
|
80
|
+
auth_header_keys = {"authorization", "x-hf-authorization"}
|
|
81
|
+
user_provided_auth_keys = {key for key in headers if key.lower() in auth_header_keys}
|
|
82
|
+
|
|
83
|
+
# OAuth is only relevant for SSE/HTTP transports and should be skipped when the
|
|
84
|
+
# user has already supplied explicit Authorization headers.
|
|
85
|
+
if server_config.transport not in ("sse", "http") or user_provided_auth_keys:
|
|
86
|
+
return headers, None, user_provided_auth_keys
|
|
87
|
+
|
|
88
|
+
oauth_auth = build_oauth_provider(server_config)
|
|
89
|
+
if oauth_auth is not None:
|
|
90
|
+
# Scrub Authorization headers so OAuth-managed credentials are the only ones sent.
|
|
91
|
+
for header_name in (
|
|
92
|
+
"Authorization",
|
|
93
|
+
"authorization",
|
|
94
|
+
"X-HF-Authorization",
|
|
95
|
+
"x-hf-authorization",
|
|
96
|
+
):
|
|
97
|
+
headers.pop(header_name, None)
|
|
98
|
+
|
|
99
|
+
return headers, oauth_auth, user_provided_auth_keys
|
|
100
|
+
|
|
101
|
+
|
|
68
102
|
class ServerConnection:
|
|
69
103
|
"""
|
|
70
104
|
Represents a long-lived MCP server connection, including:
|
|
@@ -113,7 +147,9 @@ class ServerConnection:
|
|
|
113
147
|
self.server_implementation: Implementation | None = None
|
|
114
148
|
self.client_capabilities: dict | None = None
|
|
115
149
|
self.server_instructions_available: bool = False
|
|
116
|
-
self.server_instructions_enabled: bool =
|
|
150
|
+
self.server_instructions_enabled: bool = (
|
|
151
|
+
server_config.include_instructions if server_config else True
|
|
152
|
+
)
|
|
117
153
|
self.session_id: str | None = None
|
|
118
154
|
self._get_session_id_cb: GetSessionIdCallback | None = None
|
|
119
155
|
self.transport_metrics: TransportChannelMetrics | None = None
|
|
@@ -404,7 +440,27 @@ class MCPConnectionManager(ContextDependent):
|
|
|
404
440
|
|
|
405
441
|
logger.debug(f"{server_name}: Found server configuration=", data=config.model_dump())
|
|
406
442
|
|
|
407
|
-
|
|
443
|
+
timeline_steps = 20
|
|
444
|
+
timeline_seconds = 30
|
|
445
|
+
try:
|
|
446
|
+
ctx = self.context
|
|
447
|
+
except RuntimeError:
|
|
448
|
+
ctx = None
|
|
449
|
+
|
|
450
|
+
config_obj = getattr(ctx, "config", None)
|
|
451
|
+
timeline_config = getattr(config_obj, "mcp_timeline", None)
|
|
452
|
+
if timeline_config:
|
|
453
|
+
timeline_steps = getattr(timeline_config, "steps", timeline_steps)
|
|
454
|
+
timeline_seconds = getattr(timeline_config, "step_seconds", timeline_seconds)
|
|
455
|
+
|
|
456
|
+
transport_metrics = (
|
|
457
|
+
TransportChannelMetrics(
|
|
458
|
+
bucket_seconds=timeline_seconds,
|
|
459
|
+
bucket_count=timeline_steps,
|
|
460
|
+
)
|
|
461
|
+
if config.transport in ("http", "sse", "stdio")
|
|
462
|
+
else None
|
|
463
|
+
)
|
|
408
464
|
|
|
409
465
|
def transport_context_factory():
|
|
410
466
|
if config.transport == "stdio":
|
|
@@ -425,7 +481,9 @@ class MCPConnectionManager(ContextDependent):
|
|
|
425
481
|
|
|
426
482
|
channel_hook = transport_metrics.record_event if transport_metrics else None
|
|
427
483
|
return _add_none_to_context(
|
|
428
|
-
tracking_stdio_client(
|
|
484
|
+
tracking_stdio_client(
|
|
485
|
+
server_params, channel_hook=channel_hook, errlog=error_handler
|
|
486
|
+
)
|
|
429
487
|
)
|
|
430
488
|
elif config.transport == "sse":
|
|
431
489
|
if not config.url:
|
|
@@ -434,38 +492,53 @@ class MCPConnectionManager(ContextDependent):
|
|
|
434
492
|
)
|
|
435
493
|
# Suppress MCP library error spam
|
|
436
494
|
self._suppress_mcp_sse_errors()
|
|
437
|
-
oauth_auth =
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
headers.pop("X-HF-Authorization", None)
|
|
443
|
-
return _add_none_to_context(
|
|
444
|
-
sse_client(
|
|
445
|
-
config.url,
|
|
446
|
-
headers,
|
|
447
|
-
sse_read_timeout=config.read_transport_sse_timeout_seconds,
|
|
448
|
-
auth=oauth_auth,
|
|
495
|
+
headers, oauth_auth, user_auth_keys = _prepare_headers_and_auth(config)
|
|
496
|
+
if user_auth_keys:
|
|
497
|
+
logger.debug(
|
|
498
|
+
f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
|
|
499
|
+
user_auth_headers=sorted(user_auth_keys),
|
|
449
500
|
)
|
|
501
|
+
channel_hook = None
|
|
502
|
+
if transport_metrics is not None:
|
|
503
|
+
|
|
504
|
+
def channel_hook(event):
|
|
505
|
+
try:
|
|
506
|
+
transport_metrics.record_event(event)
|
|
507
|
+
except Exception: # pragma: no cover - defensive guard
|
|
508
|
+
logger.debug(
|
|
509
|
+
"%s: transport metrics hook failed",
|
|
510
|
+
server_name,
|
|
511
|
+
exc_info=True,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
return tracking_sse_client(
|
|
515
|
+
config.url,
|
|
516
|
+
headers,
|
|
517
|
+
sse_read_timeout=config.read_transport_sse_timeout_seconds,
|
|
518
|
+
auth=oauth_auth,
|
|
519
|
+
channel_hook=channel_hook,
|
|
450
520
|
)
|
|
451
521
|
elif config.transport == "http":
|
|
452
522
|
if not config.url:
|
|
453
523
|
raise ValueError(
|
|
454
524
|
f"Server '{server_name}' uses http transport but no url is specified"
|
|
455
525
|
)
|
|
456
|
-
oauth_auth =
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
526
|
+
headers, oauth_auth, user_auth_keys = _prepare_headers_and_auth(config)
|
|
527
|
+
if user_auth_keys:
|
|
528
|
+
logger.debug(
|
|
529
|
+
f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
|
|
530
|
+
user_auth_headers=sorted(user_auth_keys),
|
|
531
|
+
)
|
|
461
532
|
channel_hook = None
|
|
462
533
|
if transport_metrics is not None:
|
|
534
|
+
|
|
463
535
|
def channel_hook(event):
|
|
464
536
|
try:
|
|
465
537
|
transport_metrics.record_event(event)
|
|
466
538
|
except Exception: # pragma: no cover - defensive guard
|
|
467
539
|
logger.debug(
|
|
468
|
-
"%s: transport metrics hook failed",
|
|
540
|
+
"%s: transport metrics hook failed",
|
|
541
|
+
server_name,
|
|
469
542
|
exc_info=True,
|
|
470
543
|
)
|
|
471
544
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from pydantic import AnyUrl, BaseModel, Field
|
|
4
|
+
|
|
5
|
+
SKYBRIDGE_MIME_TYPE = "text/html+skybridge"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SkybridgeResourceConfig(BaseModel):
|
|
9
|
+
"""Represents a Skybridge (apps SDK) resource exposed by an MCP server."""
|
|
10
|
+
|
|
11
|
+
uri: AnyUrl
|
|
12
|
+
mime_type: str | None = None
|
|
13
|
+
is_skybridge: bool = False
|
|
14
|
+
warning: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SkybridgeToolConfig(BaseModel):
|
|
18
|
+
"""Represents Skybridge metadata discovered for a tool."""
|
|
19
|
+
|
|
20
|
+
tool_name: str
|
|
21
|
+
namespaced_tool_name: str
|
|
22
|
+
template_uri: AnyUrl | None = None
|
|
23
|
+
resource_uri: AnyUrl | None = None
|
|
24
|
+
is_valid: bool = False
|
|
25
|
+
warning: str | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def display_name(self) -> str:
|
|
29
|
+
return self.namespaced_tool_name or self.tool_name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SkybridgeServerConfig(BaseModel):
|
|
33
|
+
"""Skybridge configuration discovered for a specific MCP server."""
|
|
34
|
+
|
|
35
|
+
server_name: str
|
|
36
|
+
supports_resources: bool = False
|
|
37
|
+
ui_resources: List[SkybridgeResourceConfig] = Field(default_factory=list)
|
|
38
|
+
warnings: List[str] = Field(default_factory=list)
|
|
39
|
+
tools: List[SkybridgeToolConfig] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def enabled(self) -> bool:
|
|
43
|
+
"""Return True when at least one resource advertises the Skybridge MIME type."""
|
|
44
|
+
return any(resource.is_skybridge for resource in self.ui_resources)
|
|
45
|
+
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""SSE transport wrapper that emits channel events for UI display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable
|
|
8
|
+
from urllib.parse import parse_qs, urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import httpx
|
|
12
|
+
import mcp.types as types
|
|
13
|
+
from httpx_sse import aconnect_sse
|
|
14
|
+
from httpx_sse._exceptions import SSEError
|
|
15
|
+
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
|
|
16
|
+
from mcp.shared.message import SessionMessage
|
|
17
|
+
|
|
18
|
+
from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from anyio.abc import TaskStatus
|
|
22
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
ChannelHook = Callable[[ChannelEvent], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_session_id(endpoint_url: str) -> str | None:
|
|
30
|
+
parsed = urlparse(endpoint_url)
|
|
31
|
+
query_params = parse_qs(parsed.query)
|
|
32
|
+
for key in ("sessionId", "session_id", "session"):
|
|
33
|
+
values = query_params.get(key)
|
|
34
|
+
if values:
|
|
35
|
+
return values[0]
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _emit_channel_event(
|
|
40
|
+
channel_hook: ChannelHook | None,
|
|
41
|
+
channel: ChannelName,
|
|
42
|
+
event_type: str,
|
|
43
|
+
*,
|
|
44
|
+
message: types.JSONRPCMessage | None = None,
|
|
45
|
+
raw_event: str | None = None,
|
|
46
|
+
detail: str | None = None,
|
|
47
|
+
status_code: int | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if channel_hook is None:
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
channel_hook(
|
|
53
|
+
ChannelEvent(
|
|
54
|
+
channel=channel,
|
|
55
|
+
event_type=event_type, # type: ignore[arg-type]
|
|
56
|
+
message=message,
|
|
57
|
+
raw_event=raw_event,
|
|
58
|
+
detail=detail,
|
|
59
|
+
status_code=status_code,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
except Exception:
|
|
63
|
+
logger.debug("Channel hook raised an exception", exc_info=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_http_error(exc: httpx.HTTPStatusError) -> tuple[int | None, str]:
|
|
67
|
+
status_code: int | None = None
|
|
68
|
+
detail = str(exc)
|
|
69
|
+
if exc.response is not None:
|
|
70
|
+
status_code = exc.response.status_code
|
|
71
|
+
reason = exc.response.reason_phrase or ""
|
|
72
|
+
if not reason:
|
|
73
|
+
try:
|
|
74
|
+
reason = (exc.response.text or "").strip()
|
|
75
|
+
except Exception:
|
|
76
|
+
reason = ""
|
|
77
|
+
detail = f"HTTP {status_code}: {reason or 'response'}"
|
|
78
|
+
return status_code, detail
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@asynccontextmanager
|
|
82
|
+
async def tracking_sse_client(
|
|
83
|
+
url: str,
|
|
84
|
+
headers: dict[str, Any] | None = None,
|
|
85
|
+
timeout: float = 5,
|
|
86
|
+
sse_read_timeout: float = 60 * 5,
|
|
87
|
+
httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
|
|
88
|
+
auth: httpx.Auth | None = None,
|
|
89
|
+
channel_hook: ChannelHook | None = None,
|
|
90
|
+
) -> AsyncGenerator[
|
|
91
|
+
tuple[
|
|
92
|
+
MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
93
|
+
MemoryObjectSendStream[SessionMessage],
|
|
94
|
+
Callable[[], str | None],
|
|
95
|
+
],
|
|
96
|
+
None,
|
|
97
|
+
]:
|
|
98
|
+
"""
|
|
99
|
+
Client transport for SSE with channel activity tracking.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](
|
|
103
|
+
0
|
|
104
|
+
)
|
|
105
|
+
write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
|
|
106
|
+
|
|
107
|
+
session_id: str | None = None
|
|
108
|
+
|
|
109
|
+
def get_session_id() -> str | None:
|
|
110
|
+
return session_id
|
|
111
|
+
|
|
112
|
+
async with anyio.create_task_group() as tg:
|
|
113
|
+
try:
|
|
114
|
+
logger.debug("Connecting to SSE endpoint: %s", url)
|
|
115
|
+
async with httpx_client_factory(
|
|
116
|
+
headers=headers,
|
|
117
|
+
auth=auth,
|
|
118
|
+
timeout=httpx.Timeout(timeout, read=sse_read_timeout),
|
|
119
|
+
) as client:
|
|
120
|
+
connected = False
|
|
121
|
+
post_connected = False
|
|
122
|
+
|
|
123
|
+
async def sse_reader(
|
|
124
|
+
task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
|
|
125
|
+
):
|
|
126
|
+
try:
|
|
127
|
+
async for sse in event_source.aiter_sse():
|
|
128
|
+
if sse.event == "endpoint":
|
|
129
|
+
endpoint_url = urljoin(url, sse.data)
|
|
130
|
+
logger.debug("Received SSE endpoint URL: %s", endpoint_url)
|
|
131
|
+
|
|
132
|
+
url_parsed = urlparse(url)
|
|
133
|
+
endpoint_parsed = urlparse(endpoint_url)
|
|
134
|
+
if (
|
|
135
|
+
url_parsed.scheme != endpoint_parsed.scheme
|
|
136
|
+
or url_parsed.netloc != endpoint_parsed.netloc
|
|
137
|
+
):
|
|
138
|
+
error_msg = (
|
|
139
|
+
"Endpoint origin does not match connection origin: "
|
|
140
|
+
f"{endpoint_url}"
|
|
141
|
+
)
|
|
142
|
+
logger.error(error_msg)
|
|
143
|
+
_emit_channel_event(
|
|
144
|
+
channel_hook,
|
|
145
|
+
"get",
|
|
146
|
+
"error",
|
|
147
|
+
detail=error_msg,
|
|
148
|
+
)
|
|
149
|
+
raise ValueError(error_msg)
|
|
150
|
+
|
|
151
|
+
nonlocal session_id
|
|
152
|
+
session_id = _extract_session_id(endpoint_url)
|
|
153
|
+
task_status.started(endpoint_url)
|
|
154
|
+
elif sse.event == "message":
|
|
155
|
+
try:
|
|
156
|
+
message = types.JSONRPCMessage.model_validate_json(sse.data)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.exception("Error parsing server message")
|
|
159
|
+
_emit_channel_event(
|
|
160
|
+
channel_hook,
|
|
161
|
+
"get",
|
|
162
|
+
"error",
|
|
163
|
+
detail="Error parsing server message",
|
|
164
|
+
)
|
|
165
|
+
await read_stream_writer.send(exc)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
_emit_channel_event(channel_hook, "get", "message", message=message)
|
|
169
|
+
await read_stream_writer.send(SessionMessage(message))
|
|
170
|
+
else:
|
|
171
|
+
_emit_channel_event(
|
|
172
|
+
channel_hook,
|
|
173
|
+
"get",
|
|
174
|
+
"keepalive",
|
|
175
|
+
raw_event=sse.event or "keepalive",
|
|
176
|
+
)
|
|
177
|
+
except SSEError as sse_exc:
|
|
178
|
+
logger.exception("Encountered SSE exception")
|
|
179
|
+
_emit_channel_event(
|
|
180
|
+
channel_hook,
|
|
181
|
+
"get",
|
|
182
|
+
"error",
|
|
183
|
+
detail=str(sse_exc),
|
|
184
|
+
)
|
|
185
|
+
raise
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
logger.exception("Error in sse_reader")
|
|
188
|
+
_emit_channel_event(
|
|
189
|
+
channel_hook,
|
|
190
|
+
"get",
|
|
191
|
+
"error",
|
|
192
|
+
detail=str(exc),
|
|
193
|
+
)
|
|
194
|
+
await read_stream_writer.send(exc)
|
|
195
|
+
finally:
|
|
196
|
+
await read_stream_writer.aclose()
|
|
197
|
+
|
|
198
|
+
async def post_writer(endpoint_url: str):
|
|
199
|
+
try:
|
|
200
|
+
async with write_stream_reader:
|
|
201
|
+
async for session_message in write_stream_reader:
|
|
202
|
+
try:
|
|
203
|
+
payload = session_message.message.model_dump(
|
|
204
|
+
by_alias=True,
|
|
205
|
+
mode="json",
|
|
206
|
+
exclude_none=True,
|
|
207
|
+
)
|
|
208
|
+
except Exception:
|
|
209
|
+
logger.exception("Invalid session message payload")
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
_emit_channel_event(
|
|
213
|
+
channel_hook,
|
|
214
|
+
"post-sse",
|
|
215
|
+
"message",
|
|
216
|
+
message=session_message.message,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
response = await client.post(endpoint_url, json=payload)
|
|
221
|
+
response.raise_for_status()
|
|
222
|
+
except httpx.HTTPStatusError as exc:
|
|
223
|
+
status_code, detail = _format_http_error(exc)
|
|
224
|
+
_emit_channel_event(
|
|
225
|
+
channel_hook,
|
|
226
|
+
"post-sse",
|
|
227
|
+
"error",
|
|
228
|
+
detail=detail,
|
|
229
|
+
status_code=status_code,
|
|
230
|
+
)
|
|
231
|
+
raise
|
|
232
|
+
except httpx.HTTPStatusError:
|
|
233
|
+
logger.exception("HTTP error in post_writer")
|
|
234
|
+
except Exception:
|
|
235
|
+
logger.exception("Error in post_writer")
|
|
236
|
+
_emit_channel_event(
|
|
237
|
+
channel_hook,
|
|
238
|
+
"post-sse",
|
|
239
|
+
"error",
|
|
240
|
+
detail="Error sending client message",
|
|
241
|
+
)
|
|
242
|
+
finally:
|
|
243
|
+
await write_stream.aclose()
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
async with aconnect_sse(
|
|
247
|
+
client,
|
|
248
|
+
"GET",
|
|
249
|
+
url,
|
|
250
|
+
) as event_source:
|
|
251
|
+
try:
|
|
252
|
+
event_source.response.raise_for_status()
|
|
253
|
+
except httpx.HTTPStatusError as exc:
|
|
254
|
+
status_code, detail = _format_http_error(exc)
|
|
255
|
+
_emit_channel_event(
|
|
256
|
+
channel_hook,
|
|
257
|
+
"get",
|
|
258
|
+
"error",
|
|
259
|
+
detail=detail,
|
|
260
|
+
status_code=status_code,
|
|
261
|
+
)
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
_emit_channel_event(channel_hook, "get", "connect")
|
|
265
|
+
connected = True
|
|
266
|
+
|
|
267
|
+
endpoint_url = await tg.start(sse_reader)
|
|
268
|
+
_emit_channel_event(channel_hook, "post-sse", "connect")
|
|
269
|
+
post_connected = True
|
|
270
|
+
tg.start_soon(post_writer, endpoint_url)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
yield read_stream, write_stream, get_session_id
|
|
274
|
+
finally:
|
|
275
|
+
tg.cancel_scope.cancel()
|
|
276
|
+
except Exception:
|
|
277
|
+
raise
|
|
278
|
+
finally:
|
|
279
|
+
if connected:
|
|
280
|
+
_emit_channel_event(channel_hook, "get", "disconnect")
|
|
281
|
+
if post_connected:
|
|
282
|
+
_emit_channel_event(channel_hook, "post-sse", "disconnect")
|
|
283
|
+
finally:
|
|
284
|
+
await read_stream_writer.aclose()
|
|
285
|
+
await read_stream.aclose()
|
|
286
|
+
await write_stream_reader.aclose()
|
|
287
|
+
await write_stream.aclose()
|
|
@@ -82,6 +82,8 @@ class ChannelSnapshot(BaseModel):
|
|
|
82
82
|
response_count: int = 0
|
|
83
83
|
notification_count: int = 0
|
|
84
84
|
activity_buckets: list[str] | None = None
|
|
85
|
+
activity_bucket_seconds: int | None = None
|
|
86
|
+
activity_bucket_count: int | None = None
|
|
85
87
|
|
|
86
88
|
|
|
87
89
|
class TransportSnapshot(BaseModel):
|
|
@@ -95,12 +97,18 @@ class TransportSnapshot(BaseModel):
|
|
|
95
97
|
get: ChannelSnapshot | None = None
|
|
96
98
|
resumption: ChannelSnapshot | None = None
|
|
97
99
|
stdio: ChannelSnapshot | None = None
|
|
100
|
+
activity_bucket_seconds: int | None = None
|
|
101
|
+
activity_bucket_count: int | None = None
|
|
98
102
|
|
|
99
103
|
|
|
100
104
|
class TransportChannelMetrics:
|
|
101
105
|
"""Aggregates low-level channel events into user-visible metrics."""
|
|
102
106
|
|
|
103
|
-
def __init__(
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
bucket_seconds: int | None = None,
|
|
110
|
+
bucket_count: int | None = None,
|
|
111
|
+
) -> None:
|
|
104
112
|
self._lock = Lock()
|
|
105
113
|
|
|
106
114
|
self._post_modes: set[str] = set()
|
|
@@ -155,8 +163,22 @@ class TransportChannelMetrics:
|
|
|
155
163
|
|
|
156
164
|
self._response_channel_by_id: dict[RequestId, ChannelName] = {}
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
try:
|
|
167
|
+
seconds = 30 if bucket_seconds is None else int(bucket_seconds)
|
|
168
|
+
except (TypeError, ValueError):
|
|
169
|
+
seconds = 30
|
|
170
|
+
if seconds <= 0:
|
|
171
|
+
seconds = 30
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
count = 20 if bucket_count is None else int(bucket_count)
|
|
175
|
+
except (TypeError, ValueError):
|
|
176
|
+
count = 20
|
|
177
|
+
if count <= 0:
|
|
178
|
+
count = 20
|
|
179
|
+
|
|
180
|
+
self._history_bucket_seconds = seconds
|
|
181
|
+
self._history_bucket_count = count
|
|
160
182
|
self._history_priority = {
|
|
161
183
|
"error": 5,
|
|
162
184
|
"disabled": 4,
|
|
@@ -463,6 +485,8 @@ class TransportChannelMetrics:
|
|
|
463
485
|
last_message_summary=stats.last_summary,
|
|
464
486
|
last_message_at=stats.last_at,
|
|
465
487
|
activity_buckets=self._build_activity_buckets(f"post-{mode}", now),
|
|
488
|
+
activity_bucket_seconds=self._history_bucket_seconds,
|
|
489
|
+
activity_bucket_count=self._history_bucket_count,
|
|
466
490
|
)
|
|
467
491
|
|
|
468
492
|
def snapshot(self) -> TransportSnapshot:
|
|
@@ -503,6 +527,8 @@ class TransportChannelMetrics:
|
|
|
503
527
|
response_count=self._post_response_count,
|
|
504
528
|
notification_count=self._post_notification_count,
|
|
505
529
|
activity_buckets=self._merge_activity_buckets(["post-json", "post-sse"], now),
|
|
530
|
+
activity_bucket_seconds=self._history_bucket_seconds,
|
|
531
|
+
activity_bucket_count=self._history_bucket_count,
|
|
506
532
|
)
|
|
507
533
|
|
|
508
534
|
post_json_snapshot = self._build_post_mode_snapshot("json", now)
|
|
@@ -543,6 +569,8 @@ class TransportChannelMetrics:
|
|
|
543
569
|
response_count=self._get_response_count,
|
|
544
570
|
notification_count=self._get_notification_count,
|
|
545
571
|
activity_buckets=self._build_activity_buckets("get", now),
|
|
572
|
+
activity_bucket_seconds=self._history_bucket_seconds,
|
|
573
|
+
activity_bucket_count=self._history_bucket_count,
|
|
546
574
|
)
|
|
547
575
|
|
|
548
576
|
resumption_snapshot = None
|
|
@@ -555,6 +583,8 @@ class TransportChannelMetrics:
|
|
|
555
583
|
response_count=self._resumption_response_count,
|
|
556
584
|
notification_count=self._resumption_notification_count,
|
|
557
585
|
activity_buckets=self._build_activity_buckets("resumption", now),
|
|
586
|
+
activity_bucket_seconds=self._history_bucket_seconds,
|
|
587
|
+
activity_bucket_count=self._history_bucket_count,
|
|
558
588
|
)
|
|
559
589
|
|
|
560
590
|
stdio_snapshot = None
|
|
@@ -588,6 +618,8 @@ class TransportChannelMetrics:
|
|
|
588
618
|
response_count=self._stdio_response_count,
|
|
589
619
|
notification_count=self._stdio_notification_count,
|
|
590
620
|
activity_buckets=self._build_activity_buckets("stdio", now),
|
|
621
|
+
activity_bucket_seconds=self._history_bucket_seconds,
|
|
622
|
+
activity_bucket_count=self._history_bucket_count,
|
|
591
623
|
)
|
|
592
624
|
|
|
593
625
|
return TransportSnapshot(
|
|
@@ -597,4 +629,6 @@ class TransportChannelMetrics:
|
|
|
597
629
|
get=get_snapshot,
|
|
598
630
|
resumption=resumption_snapshot,
|
|
599
631
|
stdio=stdio_snapshot,
|
|
632
|
+
activity_bucket_seconds=self._history_bucket_seconds,
|
|
633
|
+
activity_bucket_count=self._history_bucket_count,
|
|
600
634
|
)
|
fast_agent/mcp/types.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from fast_agent.interfaces import AgentProtocol
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from fast_agent.context import Context
|
|
9
|
+
from fast_agent.mcp.mcp_aggregator import MCPAggregator
|
|
10
|
+
from fast_agent.ui.console_display import ConsoleDisplay
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class McpAgentProtocol(AgentProtocol, Protocol):
|
|
15
|
+
"""Agent protocol with MCP-specific surface area."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def aggregator(self) -> MCPAggregator: ...
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def display(self) -> "ConsoleDisplay": ...
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def context(self) -> "Context | None": ...
|
|
@@ -15,6 +15,11 @@ default_model: gpt-5-mini.low
|
|
|
15
15
|
# mcp_ui_output_dir: ".fast-agent/ui" # Where to write MCP-UI HTML files (relative to CWD if not absolute)
|
|
16
16
|
# mcp_ui_mode: enabled
|
|
17
17
|
|
|
18
|
+
# MCP timeline display (adjust activity window/intervals in MCP UI + fast-agent check)
|
|
19
|
+
#mcp_timeline:
|
|
20
|
+
# steps: 20 # number of timeline buckets to render
|
|
21
|
+
# step_seconds: 30 # seconds per bucket (accepts values like "45s", "2m")
|
|
22
|
+
|
|
18
23
|
# Logging and Console Configuration:
|
|
19
24
|
logger:
|
|
20
25
|
# level: "debug" | "info" | "warning" | "error"
|