fast-agent-mcp 0.3.16__py3-none-any.whl → 0.3.18__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/mcp_agent.py +1 -1
- fast_agent/cli/commands/go.py +78 -36
- fast_agent/cli/commands/serve.py +136 -0
- fast_agent/cli/constants.py +3 -0
- fast_agent/cli/main.py +5 -3
- fast_agent/core/fastagent.py +102 -16
- fast_agent/interfaces.py +4 -0
- fast_agent/llm/model_database.py +4 -1
- fast_agent/llm/model_factory.py +4 -2
- fast_agent/llm/model_info.py +19 -43
- fast_agent/llm/provider/google/llm_google_native.py +238 -7
- fast_agent/llm/provider/openai/llm_openai.py +229 -32
- fast_agent/mcp/server/agent_server.py +175 -41
- fast_agent/skills/registry.py +17 -9
- fast_agent/tools/shell_runtime.py +4 -4
- fast_agent/ui/console_display.py +43 -1259
- fast_agent/ui/enhanced_prompt.py +26 -12
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +103 -45
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/streaming.py +638 -0
- fast_agent/ui/tool_display.py +417 -0
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.18.dist-info}/METADATA +9 -1
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.18.dist-info}/RECORD +27 -22
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.18.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.18.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.18.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,17 +3,19 @@ Enhanced AgentMCPServer with robust shutdown handling for SSE transport.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import logging
|
|
6
7
|
import os
|
|
7
8
|
import signal
|
|
9
|
+
import time
|
|
8
10
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
9
|
-
from typing import Set
|
|
11
|
+
from typing import Awaitable, Callable, Set
|
|
10
12
|
|
|
11
13
|
from mcp.server.fastmcp import Context as MCPContext
|
|
12
14
|
from mcp.server.fastmcp import FastMCP
|
|
13
15
|
|
|
14
16
|
import fast_agent.core
|
|
15
17
|
import fast_agent.core.prompt
|
|
16
|
-
from fast_agent.core.
|
|
18
|
+
from fast_agent.core.fastagent import AgentInstance
|
|
17
19
|
from fast_agent.core.logging.logger import get_logger
|
|
18
20
|
|
|
19
21
|
logger = get_logger(__name__)
|
|
@@ -24,17 +26,29 @@ class AgentMCPServer:
|
|
|
24
26
|
|
|
25
27
|
def __init__(
|
|
26
28
|
self,
|
|
27
|
-
|
|
29
|
+
primary_instance: AgentInstance,
|
|
30
|
+
create_instance: Callable[[], Awaitable[AgentInstance]],
|
|
31
|
+
dispose_instance: Callable[[AgentInstance], Awaitable[None]],
|
|
32
|
+
instance_scope: str,
|
|
28
33
|
server_name: str = "FastAgent-MCP-Server",
|
|
29
34
|
server_description: str | None = None,
|
|
35
|
+
tool_description: str | None = None,
|
|
30
36
|
) -> None:
|
|
31
37
|
"""Initialize the server with the provided agent app."""
|
|
32
|
-
self.
|
|
38
|
+
self.primary_instance = primary_instance
|
|
39
|
+
self._create_instance_task = create_instance
|
|
40
|
+
self._dispose_instance_task = dispose_instance
|
|
41
|
+
self._instance_scope = instance_scope
|
|
33
42
|
self.mcp_server: FastMCP = FastMCP(
|
|
34
43
|
name=server_name,
|
|
35
44
|
instructions=server_description
|
|
36
|
-
or f"This server provides access to {len(
|
|
45
|
+
or f"This server provides access to {len(primary_instance.agents)} agents",
|
|
37
46
|
)
|
|
47
|
+
if self._instance_scope == "request":
|
|
48
|
+
# Ensure FastMCP does not attempt to maintain sessions for stateless mode
|
|
49
|
+
self.mcp_server.settings.stateless_http = True
|
|
50
|
+
self._tool_description = tool_description
|
|
51
|
+
self._shared_instance_active = True
|
|
38
52
|
# Shutdown coordination
|
|
39
53
|
self._graceful_shutdown_event = asyncio.Event()
|
|
40
54
|
self._force_shutdown_event = asyncio.Event()
|
|
@@ -47,59 +61,187 @@ class AgentMCPServer:
|
|
|
47
61
|
# Server state
|
|
48
62
|
self._server_task = None
|
|
49
63
|
|
|
64
|
+
# Standard logging channel so we appear alongside Uvicorn/logging output
|
|
65
|
+
self.std_logger = logging.getLogger("fast_agent.server")
|
|
66
|
+
|
|
67
|
+
# Connection-scoped instance tracking
|
|
68
|
+
self._connection_instances: dict[int, AgentInstance] = {}
|
|
69
|
+
self._connection_cleanup_tasks: dict[int, Callable[[], Awaitable[None]]] = {}
|
|
70
|
+
self._connection_lock = asyncio.Lock()
|
|
71
|
+
|
|
50
72
|
# Set up agent tools
|
|
51
73
|
self.setup_tools()
|
|
52
74
|
|
|
53
|
-
logger.info(
|
|
75
|
+
logger.info(
|
|
76
|
+
f"AgentMCPServer initialized with {len(primary_instance.agents)} agents",
|
|
77
|
+
name="mcp_server_initialized",
|
|
78
|
+
agent_count=len(primary_instance.agents),
|
|
79
|
+
instance_scope=instance_scope,
|
|
80
|
+
)
|
|
54
81
|
|
|
55
82
|
def setup_tools(self) -> None:
|
|
56
83
|
"""Register all agents as MCP tools."""
|
|
57
|
-
for agent_name
|
|
58
|
-
self.register_agent_tools(agent_name
|
|
84
|
+
for agent_name in self.primary_instance.agents.keys():
|
|
85
|
+
self.register_agent_tools(agent_name)
|
|
59
86
|
|
|
60
|
-
def register_agent_tools(self, agent_name: str
|
|
87
|
+
def register_agent_tools(self, agent_name: str) -> None:
|
|
61
88
|
"""Register tools for a specific agent."""
|
|
62
89
|
|
|
63
90
|
# Basic send message tool
|
|
91
|
+
tool_description = (
|
|
92
|
+
self._tool_description.format(agent=agent_name)
|
|
93
|
+
if self._tool_description and "{agent}" in self._tool_description
|
|
94
|
+
else self._tool_description
|
|
95
|
+
)
|
|
96
|
+
|
|
64
97
|
@self.mcp_server.tool(
|
|
65
98
|
name=f"{agent_name}_send",
|
|
66
|
-
description=f"Send a message to the {agent_name} agent",
|
|
99
|
+
description=tool_description or f"Send a message to the {agent_name} agent",
|
|
67
100
|
structured_output=False,
|
|
68
101
|
# MCP 1.10.1 turns every tool in to a structured output
|
|
69
102
|
)
|
|
70
103
|
async def send_message(message: str, ctx: MCPContext) -> str:
|
|
71
104
|
"""Send a message to the agent and return its response."""
|
|
72
|
-
|
|
105
|
+
instance = await self._acquire_instance(ctx)
|
|
106
|
+
agent = instance.app[agent_name]
|
|
73
107
|
agent_context = getattr(agent, "context", None)
|
|
74
108
|
|
|
75
109
|
# Define the function to execute
|
|
76
110
|
async def execute_send():
|
|
77
|
-
|
|
111
|
+
start = time.perf_counter()
|
|
112
|
+
logger.info(
|
|
113
|
+
f"MCP request received for agent '{agent_name}'",
|
|
114
|
+
name="mcp_request_start",
|
|
115
|
+
agent=agent_name,
|
|
116
|
+
session=self._session_identifier(ctx),
|
|
117
|
+
)
|
|
118
|
+
self.std_logger.info(
|
|
119
|
+
"MCP request received for agent '%s' (scope=%s)",
|
|
120
|
+
agent_name,
|
|
121
|
+
self._instance_scope,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
response = await agent.send(message)
|
|
125
|
+
duration = time.perf_counter() - start
|
|
126
|
+
|
|
127
|
+
logger.info(
|
|
128
|
+
f"Agent '{agent_name}' completed MCP request",
|
|
129
|
+
name="mcp_request_complete",
|
|
130
|
+
agent=agent_name,
|
|
131
|
+
duration=duration,
|
|
132
|
+
session=self._session_identifier(ctx),
|
|
133
|
+
)
|
|
134
|
+
self.std_logger.info(
|
|
135
|
+
"Agent '%s' completed MCP request in %.2fs (scope=%s)",
|
|
136
|
+
agent_name,
|
|
137
|
+
duration,
|
|
138
|
+
self._instance_scope,
|
|
139
|
+
)
|
|
140
|
+
return response
|
|
78
141
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
try:
|
|
143
|
+
# Execute with bridged context
|
|
144
|
+
if agent_context and ctx:
|
|
145
|
+
return await self.with_bridged_context(agent_context, ctx, execute_send)
|
|
83
146
|
return await execute_send()
|
|
147
|
+
finally:
|
|
148
|
+
await self._release_instance(ctx, instance)
|
|
84
149
|
|
|
85
150
|
# Register a history prompt for this agent
|
|
86
151
|
@self.mcp_server.prompt(
|
|
87
152
|
name=f"{agent_name}_history",
|
|
88
153
|
description=f"Conversation history for the {agent_name} agent",
|
|
89
154
|
)
|
|
90
|
-
async def get_history_prompt() -> list:
|
|
155
|
+
async def get_history_prompt(ctx: MCPContext) -> list:
|
|
91
156
|
"""Return the conversation history as MCP messages."""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
157
|
+
instance = await self._acquire_instance(ctx)
|
|
158
|
+
agent = instance.app[agent_name]
|
|
159
|
+
try:
|
|
160
|
+
if not hasattr(agent, "_llm") or agent._llm is None:
|
|
161
|
+
return []
|
|
95
162
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
163
|
+
# Convert the multipart message history to standard PromptMessages
|
|
164
|
+
multipart_history = agent._llm.message_history
|
|
165
|
+
prompt_messages = fast_agent.core.prompt.Prompt.from_multipart(multipart_history)
|
|
99
166
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
167
|
+
# In FastMCP, we need to return the raw list of messages
|
|
168
|
+
return [{"role": msg.role, "content": msg.content} for msg in prompt_messages]
|
|
169
|
+
finally:
|
|
170
|
+
await self._release_instance(ctx, instance, reuse_connection=True)
|
|
171
|
+
|
|
172
|
+
async def _acquire_instance(self, ctx: MCPContext | None) -> AgentInstance:
|
|
173
|
+
if self._instance_scope == "shared":
|
|
174
|
+
return self.primary_instance
|
|
175
|
+
|
|
176
|
+
if self._instance_scope == "request":
|
|
177
|
+
return await self._create_instance_task()
|
|
178
|
+
|
|
179
|
+
# Connection scope
|
|
180
|
+
assert ctx is not None, "Context is required for connection-scoped instances"
|
|
181
|
+
session_key = self._connection_key(ctx)
|
|
182
|
+
async with self._connection_lock:
|
|
183
|
+
instance = self._connection_instances.get(session_key)
|
|
184
|
+
if instance is None:
|
|
185
|
+
instance = await self._create_instance_task()
|
|
186
|
+
self._connection_instances[session_key] = instance
|
|
187
|
+
self._register_session_cleanup(ctx, session_key)
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
async def _release_instance(
|
|
191
|
+
self,
|
|
192
|
+
ctx: MCPContext | None,
|
|
193
|
+
instance: AgentInstance,
|
|
194
|
+
*,
|
|
195
|
+
reuse_connection: bool = False,
|
|
196
|
+
) -> None:
|
|
197
|
+
if self._instance_scope == "request":
|
|
198
|
+
await self._dispose_instance_task(instance)
|
|
199
|
+
elif self._instance_scope == "connection" and reuse_connection is False:
|
|
200
|
+
# Connection-scoped instances persist until session cleanup
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _connection_key(self, ctx: MCPContext) -> int:
|
|
204
|
+
return id(ctx.session)
|
|
205
|
+
|
|
206
|
+
def _register_session_cleanup(self, ctx: MCPContext, session_key: int) -> None:
|
|
207
|
+
async def cleanup() -> None:
|
|
208
|
+
instance = self._connection_instances.pop(session_key, None)
|
|
209
|
+
if instance is not None:
|
|
210
|
+
await self._dispose_instance_task(instance)
|
|
211
|
+
|
|
212
|
+
exit_stack = getattr(ctx.session, "_exit_stack", None)
|
|
213
|
+
if exit_stack is not None:
|
|
214
|
+
exit_stack.push_async_callback(cleanup)
|
|
215
|
+
else:
|
|
216
|
+
self._connection_cleanup_tasks[session_key] = cleanup
|
|
217
|
+
|
|
218
|
+
def _session_identifier(self, ctx: MCPContext | None) -> str | None:
|
|
219
|
+
if ctx is None:
|
|
220
|
+
return None
|
|
221
|
+
request = getattr(ctx.request_context, "request", None)
|
|
222
|
+
if request is not None:
|
|
223
|
+
return request.headers.get("mcp-session-id")
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
async def _dispose_primary_instance(self) -> None:
|
|
227
|
+
if self._shared_instance_active:
|
|
228
|
+
try:
|
|
229
|
+
await self._dispose_instance_task(self.primary_instance)
|
|
230
|
+
finally:
|
|
231
|
+
self._shared_instance_active = False
|
|
232
|
+
|
|
233
|
+
async def _dispose_all_connection_instances(self) -> None:
|
|
234
|
+
pending_cleanups = list(self._connection_cleanup_tasks.values())
|
|
235
|
+
self._connection_cleanup_tasks.clear()
|
|
236
|
+
for cleanup in pending_cleanups:
|
|
237
|
+
await cleanup()
|
|
238
|
+
|
|
239
|
+
async with self._connection_lock:
|
|
240
|
+
instances = list(self._connection_instances.values())
|
|
241
|
+
self._connection_instances.clear()
|
|
242
|
+
|
|
243
|
+
for instance in instances:
|
|
244
|
+
await self._dispose_instance_task(instance)
|
|
103
245
|
|
|
104
246
|
def _setup_signal_handlers(self):
|
|
105
247
|
"""Set up signal handlers for graceful and forced shutdown."""
|
|
@@ -414,14 +556,8 @@ class AgentMCPServer:
|
|
|
414
556
|
"""Minimal cleanup for STDIO transport to avoid keeping process alive."""
|
|
415
557
|
logger.info("Performing minimal STDIO cleanup")
|
|
416
558
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
for agent_name, agent in self.agent_app._agents.items():
|
|
420
|
-
try:
|
|
421
|
-
if hasattr(agent, "shutdown"):
|
|
422
|
-
await agent.shutdown()
|
|
423
|
-
except Exception as e:
|
|
424
|
-
logger.error(f"Error shutting down agent {agent_name}: {e}")
|
|
559
|
+
await self._dispose_primary_instance()
|
|
560
|
+
await self._dispose_all_connection_instances()
|
|
425
561
|
|
|
426
562
|
logger.info("STDIO cleanup complete")
|
|
427
563
|
|
|
@@ -443,13 +579,11 @@ class AgentMCPServer:
|
|
|
443
579
|
# Close any resources in the exit stack
|
|
444
580
|
await self._exit_stack.aclose()
|
|
445
581
|
|
|
446
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
except Exception as e:
|
|
452
|
-
logger.error(f"Error shutting down agent {agent_name}: {e}")
|
|
582
|
+
# Dispose connection-scoped instances
|
|
583
|
+
await self._dispose_all_connection_instances()
|
|
584
|
+
|
|
585
|
+
# Dispose shared instance if still active
|
|
586
|
+
await self._dispose_primary_instance()
|
|
453
587
|
except Exception as e:
|
|
454
588
|
# Log any errors but don't let them prevent shutdown
|
|
455
589
|
logger.error(f"Error during shutdown: {e}", exc_info=True)
|
fast_agent/skills/registry.py
CHANGED
|
@@ -106,7 +106,6 @@ class SkillRegistry:
|
|
|
106
106
|
errors: List[dict[str, str]] | None = None,
|
|
107
107
|
) -> List[SkillManifest]:
|
|
108
108
|
manifests: List[SkillManifest] = []
|
|
109
|
-
cwd = Path.cwd()
|
|
110
109
|
for entry in sorted(directory.iterdir()):
|
|
111
110
|
if not entry.is_dir():
|
|
112
111
|
continue
|
|
@@ -115,13 +114,22 @@ class SkillRegistry:
|
|
|
115
114
|
continue
|
|
116
115
|
manifest, error = cls._parse_manifest(manifest_path)
|
|
117
116
|
if manifest:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
# Compute relative path from skills directory (not cwd)
|
|
118
|
+
# Old behavior: try both cwd and directory
|
|
119
|
+
# relative_path: Path | None = None
|
|
120
|
+
# for base in (cwd, directory):
|
|
121
|
+
# try:
|
|
122
|
+
# relative_path = manifest_path.relative_to(base)
|
|
123
|
+
# break
|
|
124
|
+
# except ValueError:
|
|
125
|
+
# continue
|
|
126
|
+
|
|
127
|
+
# New behavior: always relative to skills directory
|
|
128
|
+
try:
|
|
129
|
+
relative_path = manifest_path.relative_to(directory)
|
|
130
|
+
except ValueError:
|
|
131
|
+
relative_path = None
|
|
132
|
+
|
|
125
133
|
manifest = replace(manifest, relative_path=relative_path)
|
|
126
134
|
manifests.append(manifest)
|
|
127
135
|
elif errors is not None:
|
|
@@ -175,7 +183,7 @@ def format_skills_for_prompt(manifests: Sequence[SkillManifest]) -> str:
|
|
|
175
183
|
preamble = (
|
|
176
184
|
"Skills provide specialized capabilities and domain knowledge. Use a Skill if it seems in any way "
|
|
177
185
|
"relevant to the Users task, intent or would increase effectiveness. \n"
|
|
178
|
-
"The 'execute' tool gives you shell access to the current working directory (agent workspace) "
|
|
186
|
+
"The 'execute' tool gives you direct shell access to the current working directory (agent workspace) "
|
|
179
187
|
"and outputted files are visible to the User.\n"
|
|
180
188
|
"To use a Skill you must first read the SKILL.md file (use 'execute' tool).\n "
|
|
181
189
|
"Only use skills listed in <available_skills> below.\n\n"
|
|
@@ -42,13 +42,13 @@ class ShellRuntime:
|
|
|
42
42
|
|
|
43
43
|
self._tool = Tool(
|
|
44
44
|
name="execute",
|
|
45
|
-
description=f"Run a shell command
|
|
45
|
+
description=f"Run a shell command directly in {shell_name}.",
|
|
46
46
|
inputSchema={
|
|
47
47
|
"type": "object",
|
|
48
48
|
"properties": {
|
|
49
49
|
"command": {
|
|
50
50
|
"type": "string",
|
|
51
|
-
"description": "
|
|
51
|
+
"description": "Command string only - no shell executable prefix (correct: 'pwd', incorrect: 'bash -c pwd').",
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"required": ["command"],
|
|
@@ -71,8 +71,8 @@ class ShellRuntime:
|
|
|
71
71
|
def working_directory(self) -> Path:
|
|
72
72
|
"""Return the working directory used for shell execution."""
|
|
73
73
|
# TODO -- reinstate when we provide duplication/isolation of skill workspaces
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
if self._skills_directory and self._skills_directory.exists():
|
|
75
|
+
return self._skills_directory
|
|
76
76
|
return Path.cwd()
|
|
77
77
|
|
|
78
78
|
def runtime_info(self) -> Dict[str, str | None]:
|