fast-agent-mcp 0.2.18__py3-none-any.whl → 0.2.20__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.
@@ -12,8 +12,6 @@ from typing import (
12
12
 
13
13
  from mcp import GetPromptResult, ReadResourceResult
14
14
  from mcp.client.session import ClientSession
15
- from mcp.server.lowlevel.server import Server
16
- from mcp.server.stdio import stdio_server
17
15
  from mcp.types import (
18
16
  CallToolResult,
19
17
  ListToolsResult,
@@ -21,6 +19,7 @@ from mcp.types import (
21
19
  TextContent,
22
20
  Tool,
23
21
  )
22
+ from opentelemetry import trace
24
23
  from pydantic import AnyUrl, BaseModel, ConfigDict
25
24
 
26
25
  from mcp_agent.context_dependent import ContextDependent
@@ -42,6 +41,14 @@ SEP = "-"
42
41
  T = TypeVar("T")
43
42
  R = TypeVar("R")
44
43
 
44
+ def create_namespaced_name(server_name: str, resource_name: str) -> str:
45
+ """Create a namespaced resource name from server and resource names"""
46
+ return f"{server_name}{SEP}{resource_name}"
47
+
48
+ def is_namespaced_name(name: str) -> bool:
49
+ """Check if a name is already namespaced"""
50
+ return SEP in name
51
+
45
52
 
46
53
  class NamespacedTool(BaseModel):
47
54
  """
@@ -230,8 +237,7 @@ class MCPAggregator(ContextDependent):
230
237
 
231
238
  async def fetch_prompts(client: ClientSession, server_name: str) -> List[Prompt]:
232
239
  # Only fetch prompts if the server supports them
233
- capabilities = await self.get_capabilities(server_name)
234
- if not capabilities or not capabilities.prompts:
240
+ if not await self.server_supports_feature(server_name, "prompts"):
235
241
  logger.debug(f"Server '{server_name}' does not support prompts")
236
242
  return []
237
243
 
@@ -277,7 +283,7 @@ class MCPAggregator(ContextDependent):
277
283
  # Process tools
278
284
  self._server_to_tool_map[server_name] = []
279
285
  for tool in tools:
280
- namespaced_tool_name = f"{server_name}{SEP}{tool.name}"
286
+ namespaced_tool_name = create_namespaced_name(server_name, tool.name)
281
287
  namespaced_tool = NamespacedTool(
282
288
  tool=tool,
283
289
  server_name=server_name,
@@ -319,6 +325,41 @@ class MCPAggregator(ContextDependent):
319
325
  except Exception as e:
320
326
  logger.debug(f"Error getting capabilities for server '{server_name}': {e}")
321
327
  return None
328
+
329
+ async def validate_server(self, server_name: str) -> bool:
330
+ """
331
+ Validate that a server exists in our server list.
332
+
333
+ Args:
334
+ server_name: Name of the server to validate
335
+
336
+ Returns:
337
+ True if the server exists, False otherwise
338
+ """
339
+ valid = server_name in self.server_names
340
+ if not valid:
341
+ logger.debug(f"Server '{server_name}' not found")
342
+ return valid
343
+
344
+ async def server_supports_feature(self, server_name: str, feature: str) -> bool:
345
+ """
346
+ Check if a server supports a specific feature.
347
+
348
+ Args:
349
+ server_name: Name of the server to check
350
+ feature: Feature to check for (e.g., "prompts", "resources")
351
+
352
+ Returns:
353
+ True if the server supports the feature, False otherwise
354
+ """
355
+ if not await self.validate_server(server_name):
356
+ return False
357
+
358
+ capabilities = await self.get_capabilities(server_name)
359
+ if not capabilities:
360
+ return False
361
+
362
+ return getattr(capabilities, feature, False)
322
363
 
323
364
  async def list_servers(self) -> List[str]:
324
365
  """Return the list of server names aggregated by this agent."""
@@ -419,40 +460,45 @@ class MCPAggregator(ContextDependent):
419
460
  Returns:
420
461
  Tuple of (server_name, local_resource_name)
421
462
  """
422
- server_name = None
423
- local_name = None
424
-
425
- if SEP in name: # Namespaced resource name
426
- server_name, local_name = name.split(SEP, 1)
427
- else:
428
- # For tools, search all servers for the tool
429
- if resource_type == "tool":
430
- for _, tools in self._server_to_tool_map.items():
431
- for namespaced_tool in tools:
432
- if namespaced_tool.tool.name == name:
433
- server_name = namespaced_tool.server_name
434
- local_name = name
435
- break
436
- if server_name:
437
- break
438
- # For all other resource types, use the first server
439
- # (prompt resource type is specially handled in get_prompt)
440
- else:
441
- local_name = name
442
- server_name = self.server_names[0] if self.server_names else None
443
-
444
- return server_name, local_name
463
+ # First, check if this is a direct hit in our namespaced tool map
464
+ # This handles both namespaced and non-namespaced direct lookups
465
+ if resource_type == "tool" and name in self._namespaced_tool_map:
466
+ namespaced_tool = self._namespaced_tool_map[name]
467
+ return namespaced_tool.server_name, namespaced_tool.tool.name
468
+
469
+ # Next, attempt to interpret as a namespaced name
470
+ if is_namespaced_name(name):
471
+ parts = name.split(SEP, 1)
472
+ server_name, local_name = parts[0], parts[1]
473
+
474
+ # Validate that the parsed server actually exists
475
+ if server_name in self.server_names:
476
+ return server_name, local_name
477
+
478
+ # If the server name doesn't exist, it might be a tool with a hyphen in its name
479
+ # Fall through to the next checks
480
+
481
+ # For tools, search all servers for the tool by exact name match
482
+ if resource_type == "tool":
483
+ for server_name, tools in self._server_to_tool_map.items():
484
+ for namespaced_tool in tools:
485
+ if namespaced_tool.tool.name == name:
486
+ return server_name, name
487
+
488
+ # For all other resource types, use the first server
489
+ return (self.server_names[0] if self.server_names else None, name)
445
490
 
446
491
  async def call_tool(self, name: str, arguments: dict | None = None) -> CallToolResult:
447
492
  """
448
- Call a namespaced tool, e.g., 'server_name.tool_name'.
493
+ Call a namespaced tool, e.g., 'server_name-tool_name'.
449
494
  """
450
495
  if not self.initialized:
451
496
  await self.load_servers()
452
497
 
498
+ # Use the common parser to get server and tool name
453
499
  server_name, local_tool_name = await self._parse_resource_name(name, "tool")
454
500
 
455
- if server_name is None or local_tool_name is None:
501
+ if server_name is None:
456
502
  logger.error(f"Error: Tool '{name}' not found")
457
503
  return CallToolResult(
458
504
  isError=True,
@@ -469,16 +515,20 @@ class MCPAggregator(ContextDependent):
469
515
  },
470
516
  )
471
517
 
472
- return await self._execute_on_server(
473
- server_name=server_name,
474
- operation_type="tool",
475
- operation_name=local_tool_name,
476
- method_name="call_tool",
477
- method_args={"name": local_tool_name, "arguments": arguments},
478
- error_factory=lambda msg: CallToolResult(
479
- isError=True, content=[TextContent(type="text", text=msg)]
480
- ),
481
- )
518
+ tracer = trace.get_tracer(__name__)
519
+ with tracer.start_as_current_span(f"MCP Tool: {server_name}/{local_tool_name}"):
520
+ trace.get_current_span().set_attribute("tool_name", local_tool_name)
521
+ trace.get_current_span().set_attribute("server_name", server_name)
522
+ return await self._execute_on_server(
523
+ server_name=server_name,
524
+ operation_type="tool",
525
+ operation_name=local_tool_name,
526
+ method_name="call_tool",
527
+ method_args={"name": local_tool_name, "arguments": arguments},
528
+ error_factory=lambda msg: CallToolResult(
529
+ isError=True, content=[TextContent(type="text", text=msg)]
530
+ ),
531
+ )
482
532
 
483
533
  async def get_prompt(
484
534
  self,
@@ -501,27 +551,37 @@ class MCPAggregator(ContextDependent):
501
551
  if not self.initialized:
502
552
  await self.load_servers()
503
553
 
504
- # Handle the case where prompt_name is None
505
- if SEP in prompt_name and server_name is None:
506
- server_name, local_prompt_name = prompt_name.split(SEP, 1)
507
- namespaced_name = prompt_name # Already namespaced
508
- # Plain prompt name - use provided server or search
554
+ # If server_name is explicitly provided, use it
555
+ if server_name:
556
+ local_prompt_name = prompt_name
557
+ # Otherwise, check if prompt_name is namespaced and validate the server exists
558
+ elif is_namespaced_name(prompt_name):
559
+ parts = prompt_name.split(SEP, 1)
560
+ potential_server = parts[0]
561
+
562
+ # Only treat as namespaced if the server part is valid
563
+ if potential_server in self.server_names:
564
+ server_name = potential_server
565
+ local_prompt_name = parts[1]
566
+ else:
567
+ # The hyphen is part of the prompt name, not a namespace separator
568
+ local_prompt_name = prompt_name
569
+ # Otherwise, use prompt_name as-is for searching
509
570
  else:
510
571
  local_prompt_name = prompt_name
511
- namespaced_name = None # Will be set when server is found
512
-
572
+ # We'll search all servers below
573
+
513
574
  # If we have a specific server to check
514
575
  if server_name:
515
- if server_name not in self.server_names:
576
+ if not await self.validate_server(server_name):
516
577
  logger.error(f"Error: Server '{server_name}' not found")
517
578
  return GetPromptResult(
518
579
  description=f"Error: Server '{server_name}' not found",
519
580
  messages=[],
520
581
  )
521
-
582
+
522
583
  # Check if server supports prompts
523
- capabilities = await self.get_capabilities(server_name)
524
- if not capabilities or not capabilities.prompts:
584
+ if not await self.server_supports_feature(server_name, "prompts"):
525
585
  logger.debug(f"Server '{server_name}' does not support prompts")
526
586
  return GetPromptResult(
527
587
  description=f"Server '{server_name}' does not support prompts",
@@ -559,7 +619,7 @@ class MCPAggregator(ContextDependent):
559
619
 
560
620
  # Add namespaced name and source server to the result
561
621
  if result and result.messages:
562
- result.namespaced_name = namespaced_name or f"{server_name}{SEP}{local_prompt_name}"
622
+ result.namespaced_name = create_namespaced_name(server_name, local_prompt_name)
563
623
 
564
624
  # Store the arguments in the result for display purposes
565
625
  if arguments:
@@ -611,7 +671,7 @@ class MCPAggregator(ContextDependent):
611
671
  f"Successfully retrieved prompt '{local_prompt_name}' from server '{s_name}'"
612
672
  )
613
673
  # Add namespaced name using the actual server where found
614
- result.namespaced_name = f"{s_name}{SEP}{local_prompt_name}"
674
+ result.namespaced_name = create_namespaced_name(s_name, local_prompt_name)
615
675
 
616
676
  # Store the arguments in the result for display purposes
617
677
  if arguments:
@@ -659,7 +719,7 @@ class MCPAggregator(ContextDependent):
659
719
  f"Found prompt '{local_prompt_name}' on server '{s_name}' (not in cache)"
660
720
  )
661
721
  # Add namespaced name using the actual server where found
662
- result.namespaced_name = f"{s_name}{SEP}{local_prompt_name}"
722
+ result.namespaced_name = create_namespaced_name(s_name, local_prompt_name)
663
723
 
664
724
  # Store the arguments in the result for display purposes
665
725
  if arguments:
@@ -937,68 +997,3 @@ class MCPAggregator(ContextDependent):
937
997
  logger.error(f"Error fetching resources from {s_name}: {e}")
938
998
 
939
999
  return results
940
-
941
-
942
- class MCPCompoundServer(Server):
943
- """
944
- A compound server (server-of-servers) that aggregates multiple MCP servers and is itself an MCP server
945
- """
946
-
947
- def __init__(self, server_names: List[str], name: str = "MCPCompoundServer") -> None:
948
- super().__init__(name)
949
- self.aggregator = MCPAggregator(server_names)
950
-
951
- # Register handlers for tools, prompts, and resources
952
- self.list_tools()(self._list_tools)
953
- self.call_tool()(self._call_tool)
954
- self.get_prompt()(self._get_prompt)
955
- self.list_prompts()(self._list_prompts)
956
-
957
- async def _list_tools(self) -> List[Tool]:
958
- """List all tools aggregated from connected MCP servers."""
959
- tools_result = await self.aggregator.list_tools()
960
- return tools_result.tools
961
-
962
- async def _call_tool(self, name: str, arguments: dict | None = None) -> CallToolResult:
963
- """Call a specific tool from the aggregated servers."""
964
- try:
965
- result = await self.aggregator.call_tool(name=name, arguments=arguments)
966
- return result.content
967
- except Exception as e:
968
- return CallToolResult(
969
- isError=True,
970
- content=[TextContent(type="text", text=f"Error calling tool: {e}")],
971
- )
972
-
973
- async def _get_prompt(
974
- self, name: str = None, arguments: dict[str, str] = None
975
- ) -> GetPromptResult:
976
- """
977
- Get a prompt from the aggregated servers.
978
-
979
- Args:
980
- name: Name of the prompt to get (optionally namespaced)
981
- arguments: Optional dictionary of string arguments for prompt templating
982
- """
983
- try:
984
- result = await self.aggregator.get_prompt(prompt_name=name, arguments=arguments)
985
- return result
986
- except Exception as e:
987
- return GetPromptResult(description=f"Error getting prompt: {e}", messages=[])
988
-
989
- async def _list_prompts(self, server_name: str = None) -> Dict[str, List[Prompt]]:
990
- """List available prompts from the aggregated servers."""
991
- try:
992
- return await self.aggregator.list_prompts(server_name=server_name)
993
- except Exception as e:
994
- logger.error(f"Error listing prompts: {e}")
995
- return {}
996
-
997
- async def run_stdio_async(self) -> None:
998
- """Run the server using stdio transport."""
999
- async with stdio_server() as (read_stream, write_stream):
1000
- await self.run(
1001
- read_stream=read_stream,
1002
- write_stream=write_stream,
1003
- initialization_options=self.create_initialization_options(),
1004
- )
@@ -262,8 +262,9 @@ class MCPConnectionManager(ContextDependent):
262
262
  if config.transport == "stdio":
263
263
  server_params = StdioServerParameters(
264
264
  command=config.command,
265
- args=config.args,
265
+ args=config.args if config.args is not None else [],
266
266
  env={**get_default_environment(), **(config.env or {})},
267
+ cwd=config.cwd,
267
268
  )
268
269
  # Create custom error handler to ensure all output is captured
269
270
  error_handler = get_stderr_handler(server_name)
@@ -110,7 +110,10 @@ class AgentMCPServer:
110
110
 
111
111
  # Register handlers for SIGINT (Ctrl+C) and SIGTERM
112
112
  for sig, is_term in [(signal.SIGINT, False), (signal.SIGTERM, True)]:
113
- loop.add_signal_handler(sig, lambda term=is_term: handle_signal(term))
113
+ import platform
114
+
115
+ if platform.system() != "Windows":
116
+ loop.add_signal_handler(sig, lambda term=is_term: handle_signal(term))
114
117
 
115
118
  logger.debug("Signal handlers installed")
116
119
 
@@ -128,6 +128,7 @@ class ServerRegistry:
128
128
  command=config.command,
129
129
  args=config.args,
130
130
  env={**get_default_environment(), **(config.env or {})},
131
+ cwd=config.cwd,
131
132
  )
132
133
 
133
134
  # Create a stderr handler that logs to our application logger
@@ -1,138 +0,0 @@
1
- """
2
- Telemetry manager that defines distributed tracing decorators for OpenTelemetry traces/spans
3
- for the Logger module for MCP Agent
4
- """
5
-
6
- import asyncio
7
- import functools
8
- from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
9
-
10
- from opentelemetry import trace
11
- from opentelemetry.context import Context as OtelContext
12
- from opentelemetry.propagate import extract as otel_extract
13
- from opentelemetry.trace import SpanKind, Status, StatusCode, set_span_in_context
14
- from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
15
-
16
- from mcp_agent.context_dependent import ContextDependent
17
-
18
- if TYPE_CHECKING:
19
- from mcp_agent.context import Context
20
-
21
-
22
- class TelemetryManager(ContextDependent):
23
- """
24
- Simple manager for creating OpenTelemetry spans automatically.
25
- Decorator usage: @telemetry.traced("SomeSpanName")
26
- """
27
-
28
- def __init__(self, context: Optional["Context"] = None, **kwargs) -> None:
29
- # If needed, configure resources, exporters, etc.
30
- # E.g.: from opentelemetry.sdk.trace import TracerProvider
31
- # trace.set_tracer_provider(TracerProvider(...))
32
- super().__init__(context=context, **kwargs)
33
-
34
- def traced(
35
- self,
36
- name: str | None = None,
37
- kind: SpanKind = SpanKind.INTERNAL,
38
- attributes: Dict[str, Any] = None,
39
- ) -> Callable:
40
- """
41
- Decorator that automatically creates and manages a span for a function.
42
- Works for both async and sync functions.
43
- """
44
-
45
- def decorator(func):
46
- span_name = name or f"{func.__module__}.{func.__qualname__}"
47
-
48
- tracer = self.context.tracer or trace.get_tracer("mcp_agent")
49
-
50
- @functools.wraps(func)
51
- async def async_wrapper(*args, **kwargs):
52
- with tracer.start_as_current_span(span_name, kind=kind) as span:
53
- if attributes:
54
- for k, v in attributes.items():
55
- span.set_attribute(k, v)
56
- # Record simple args
57
- self._record_args(span, args, kwargs)
58
- try:
59
- res = await func(*args, **kwargs)
60
- return res
61
- except Exception as e:
62
- span.record_exception(e)
63
- span.set_status(Status(StatusCode.ERROR))
64
- raise
65
-
66
- @functools.wraps(func)
67
- def sync_wrapper(*args, **kwargs):
68
- with tracer.start_as_current_span(span_name, kind=kind) as span:
69
- if attributes:
70
- for k, v in attributes.items():
71
- span.set_attribute(k, v)
72
- # Record simple args
73
- self._record_args(span, args, kwargs)
74
- try:
75
- res = func(*args, **kwargs)
76
- return res
77
- except Exception as e:
78
- span.record_exception(e)
79
- span.set_status(Status(StatusCode.ERROR))
80
- raise
81
-
82
- if asyncio.iscoroutinefunction(func):
83
- return async_wrapper
84
- else:
85
- return sync_wrapper
86
-
87
- return decorator
88
-
89
- def _record_args(self, span, args, kwargs) -> None:
90
- """Optionally record primitive args as span attributes."""
91
- for i, arg in enumerate(args):
92
- if isinstance(arg, (str, int, float, bool)):
93
- span.set_attribute(f"arg_{i}", str(arg))
94
- for k, v in kwargs.items():
95
- if isinstance(v, (str, int, float, bool)):
96
- span.set_attribute(k, str(v))
97
-
98
-
99
- class MCPRequestTrace:
100
- """Helper class for trace context propagation in MCP"""
101
-
102
- @staticmethod
103
- def start_span_from_mcp_request(
104
- method: str, params: Dict[str, Any]
105
- ) -> Tuple[trace.Span, OtelContext]:
106
- """Extract trace context from incoming MCP request and start a new span"""
107
- # Extract trace context from _meta if present
108
- carrier = {}
109
- _meta = params.get("_meta", {})
110
- if "traceparent" in _meta:
111
- carrier["traceparent"] = _meta["traceparent"]
112
- if "tracestate" in _meta:
113
- carrier["tracestate"] = _meta["tracestate"]
114
-
115
- # Extract context and start span
116
- ctx = otel_extract(carrier, context=OtelContext())
117
- tracer = trace.get_tracer(__name__)
118
- span = tracer.start_span(method, context=ctx, kind=SpanKind.SERVER)
119
- return span, set_span_in_context(span)
120
-
121
- @staticmethod
122
- def inject_trace_context(arguments: Dict[str, Any]) -> Dict[str, Any]:
123
- """Inject current trace context into outgoing MCP request arguments"""
124
- carrier = {}
125
- TraceContextTextMapPropagator().inject(carrier)
126
-
127
- # Create or update _meta with trace context
128
- _meta = arguments.get("_meta", {})
129
- if "traceparent" in carrier:
130
- _meta["traceparent"] = carrier["traceparent"]
131
- if "tracestate" in carrier:
132
- _meta["tracestate"] = carrier["tracestate"]
133
- arguments["_meta"] = _meta
134
-
135
- return arguments
136
-
137
-
138
- telemetry = TelemetryManager()