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.

@@ -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.agent_app import AgentApp
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
- agent_app: AgentApp,
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.agent_app = agent_app
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(agent_app._agents)} agents",
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(f"AgentMCPServer initialized with {len(agent_app._agents)} agents")
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, agent in self.agent_app._agents.items():
58
- self.register_agent_tools(agent_name, agent)
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, agent) -> None:
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
- # Get the agent's context
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
- return await agent.send(message)
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
- # Execute with bridged context
80
- if agent_context and ctx:
81
- return await self.with_bridged_context(agent_context, ctx, execute_send)
82
- else:
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
- # Get the conversation history from the agent's LLM
93
- if not hasattr(agent, "_llm") or agent._llm is None:
94
- return []
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
- # Convert the multipart message history to standard PromptMessages
97
- multipart_history = agent._llm.message_history
98
- prompt_messages = fast_agent.core.prompt.Prompt.from_multipart(multipart_history)
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
- # In FastMCP, we need to return the raw list of messages
101
- # that matches the structure that FastMCP expects (list of dicts with role/content)
102
- return [{"role": msg.role, "content": msg.content} for msg in prompt_messages]
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
- # Just clean up agent resources directly without the full shutdown sequence
418
- # This preserves the natural exit process for STDIO
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
- # Shutdown any agent resources
447
- for agent_name, agent in self.agent_app._agents.items():
448
- try:
449
- if hasattr(agent, "shutdown"):
450
- await agent.shutdown()
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)
@@ -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
- relative_path: Path | None = None
119
- for base in (cwd, directory):
120
- try:
121
- relative_path = manifest_path.relative_to(base)
122
- break
123
- except ValueError:
124
- continue
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 ({shell_name}) inside the agent workspace and return its output.",
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": "Shell command to execute (e.g. 'cat README.md').",
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
- # if self._skills_directory and self._skills_directory.exists():
75
- # return self._skills_directory
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]: