fast-agent-mcp 0.1.8__py3-none-any.whl → 0.1.10__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.
Files changed (54) hide show
  1. {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.10.dist-info}/METADATA +27 -4
  2. {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.10.dist-info}/RECORD +51 -30
  3. {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.10.dist-info}/entry_points.txt +1 -0
  4. mcp_agent/agents/agent.py +114 -8
  5. mcp_agent/context.py +0 -2
  6. mcp_agent/core/agent_app.py +89 -13
  7. mcp_agent/core/factory.py +14 -13
  8. mcp_agent/core/fastagent.py +15 -5
  9. mcp_agent/core/mcp_content.py +222 -0
  10. mcp_agent/core/prompt.py +132 -0
  11. mcp_agent/core/proxies.py +79 -36
  12. mcp_agent/logging/listeners.py +3 -6
  13. mcp_agent/logging/transport.py +30 -3
  14. mcp_agent/mcp/mcp_agent_client_session.py +21 -145
  15. mcp_agent/mcp/mcp_aggregator.py +61 -12
  16. mcp_agent/mcp/mcp_connection_manager.py +0 -1
  17. mcp_agent/mcp/mime_utils.py +69 -0
  18. mcp_agent/mcp/prompt_message_multipart.py +64 -0
  19. mcp_agent/mcp/prompt_serialization.py +447 -0
  20. mcp_agent/mcp/prompts/__init__.py +0 -0
  21. mcp_agent/mcp/prompts/__main__.py +10 -0
  22. mcp_agent/mcp/prompts/prompt_server.py +509 -0
  23. mcp_agent/mcp/prompts/prompt_template.py +469 -0
  24. mcp_agent/mcp/resource_utils.py +223 -0
  25. mcp_agent/mcp/stdio.py +23 -15
  26. mcp_agent/mcp_server_registry.py +5 -2
  27. mcp_agent/resources/examples/internal/agent.py +1 -1
  28. mcp_agent/resources/examples/internal/fastagent.config.yaml +2 -2
  29. mcp_agent/resources/examples/internal/sizer.py +0 -5
  30. mcp_agent/resources/examples/prompting/__init__.py +3 -0
  31. mcp_agent/resources/examples/prompting/agent.py +23 -0
  32. mcp_agent/resources/examples/prompting/fastagent.config.yaml +44 -0
  33. mcp_agent/resources/examples/prompting/image_server.py +56 -0
  34. mcp_agent/resources/examples/workflows/orchestrator.py +3 -3
  35. mcp_agent/workflows/llm/anthropic_utils.py +101 -0
  36. mcp_agent/workflows/llm/augmented_llm.py +139 -66
  37. mcp_agent/workflows/llm/augmented_llm_anthropic.py +127 -251
  38. mcp_agent/workflows/llm/augmented_llm_openai.py +149 -305
  39. mcp_agent/workflows/llm/augmented_llm_passthrough.py +99 -1
  40. mcp_agent/workflows/llm/augmented_llm_playback.py +109 -0
  41. mcp_agent/workflows/llm/model_factory.py +20 -3
  42. mcp_agent/workflows/llm/openai_utils.py +65 -0
  43. mcp_agent/workflows/llm/providers/__init__.py +8 -0
  44. mcp_agent/workflows/llm/providers/multipart_converter_anthropic.py +348 -0
  45. mcp_agent/workflows/llm/providers/multipart_converter_openai.py +426 -0
  46. mcp_agent/workflows/llm/providers/openai_multipart.py +197 -0
  47. mcp_agent/workflows/llm/providers/sampling_converter_anthropic.py +258 -0
  48. mcp_agent/workflows/llm/providers/sampling_converter_openai.py +229 -0
  49. mcp_agent/workflows/llm/sampling_format_converter.py +39 -0
  50. mcp_agent/core/server_validation.py +0 -44
  51. mcp_agent/core/simulator_registry.py +0 -22
  52. mcp_agent/workflows/llm/enhanced_passthrough.py +0 -70
  53. {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.10.dist-info}/WHEEL +0 -0
  54. {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.10.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,509 @@
1
+ """
2
+ FastMCP Prompt Server V2
3
+
4
+ A server that loads prompts from text files with simple delimiters and serves them via MCP.
5
+ Uses the prompt_template module for clean, testable handling of prompt templates.
6
+ """
7
+
8
+ import asyncio
9
+ import argparse
10
+ import base64
11
+ import logging
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import List, Dict, Optional, Callable, Awaitable, Literal, Any
15
+ from mcp.server.fastmcp.resources import FileResource
16
+ from pydantic import AnyUrl
17
+
18
+ from mcp_agent.mcp import mime_utils, resource_utils
19
+
20
+ from mcp.server.fastmcp import FastMCP
21
+ from mcp.server.fastmcp.prompts.base import (
22
+ UserMessage,
23
+ AssistantMessage,
24
+ Message,
25
+ )
26
+ from mcp.types import (
27
+ TextContent,
28
+ )
29
+
30
+ from mcp_agent.mcp.prompts.prompt_template import (
31
+ PromptTemplateLoader,
32
+ PromptMetadata,
33
+ PromptContent,
34
+ PromptTemplate,
35
+ )
36
+
37
+ # Configure logging
38
+ logging.basicConfig(level=logging.INFO)
39
+ logger = logging.getLogger("prompt_server")
40
+
41
+ # Create FastMCP server
42
+ mcp = FastMCP("Prompt Server")
43
+
44
+
45
+ class PromptConfig(PromptMetadata):
46
+ """Configuration for the prompt server"""
47
+
48
+ prompt_files: List[Path] = []
49
+ user_delimiter: str = "---USER"
50
+ assistant_delimiter: str = "---ASSISTANT"
51
+ resource_delimiter: str = "---RESOURCE"
52
+ http_timeout: float = 10.0
53
+ transport: str = "stdio"
54
+ port: int = 8000
55
+
56
+
57
+ # Will be initialized with command line args
58
+ config = None
59
+
60
+ # We'll maintain registries of all exposed resources and prompts
61
+ exposed_resources: Dict[str, Path] = {}
62
+ prompt_registry: Dict[str, PromptMetadata] = {}
63
+
64
+ # Define message role type
65
+ MessageRole = Literal["user", "assistant"]
66
+
67
+
68
+ def create_content_message(text: str, role: MessageRole) -> Message:
69
+ """Create a text content message with the specified role"""
70
+ message_class = UserMessage if role == "user" else AssistantMessage
71
+ return message_class(content=TextContent(type="text", text=text))
72
+
73
+
74
+ def create_resource_message(
75
+ resource_path: str, content: str, mime_type: str, is_binary: bool, role: MessageRole
76
+ ) -> Message:
77
+ """Create a resource message with the specified content and role"""
78
+ message_class = UserMessage if role == "user" else AssistantMessage
79
+
80
+ if mime_utils.is_image_mime_type(mime_type):
81
+ # For images, create an ImageContent
82
+ image_content = resource_utils.create_image_content(
83
+ data=content, mime_type=mime_type
84
+ )
85
+ return message_class(content=image_content)
86
+ else:
87
+ # For other resources, create an EmbeddedResource
88
+ embedded_resource = resource_utils.create_embedded_resource(
89
+ resource_path, content, mime_type, is_binary
90
+ )
91
+ return message_class(content=embedded_resource)
92
+
93
+
94
+ def create_messages_with_resources(
95
+ content_sections: List[PromptContent], prompt_files: List[Path]
96
+ ) -> List[Message]:
97
+ """
98
+ Create a list of messages from content sections, with resources properly handled.
99
+
100
+ This implementation produces one message for each content section's text,
101
+ followed by separate messages for each resource (with the same role type
102
+ as the section they belong to).
103
+
104
+ Args:
105
+ content_sections: List of PromptContent objects
106
+ prompt_files: List of prompt files (to help locate resource files)
107
+
108
+ Returns:
109
+ List of Message objects
110
+ """
111
+ messages = []
112
+
113
+ for section in content_sections:
114
+ # Convert to our literal type for role
115
+ role = cast_message_role(section.role)
116
+
117
+ # Add the text message
118
+ messages.append(create_content_message(section.text, role))
119
+
120
+ # Add resource messages with the same role type as the section
121
+ for resource_path in section.resources:
122
+ try:
123
+ # Load resource with information about its type
124
+ resource_content, mime_type, is_binary = (
125
+ resource_utils.load_resource_content(resource_path, prompt_files)
126
+ )
127
+
128
+ # Create and add the resource message
129
+ resource_message = create_resource_message(
130
+ resource_path, resource_content, mime_type, is_binary, role
131
+ )
132
+ messages.append(resource_message)
133
+ except Exception as e:
134
+ logger.error(f"Error loading resource {resource_path}: {e}")
135
+
136
+ return messages
137
+
138
+
139
+ def cast_message_role(role: str) -> MessageRole:
140
+ """Cast a string role to a MessageRole literal type"""
141
+ if role == "user" or role == "assistant":
142
+ return role # type: ignore
143
+ # Default to user if the role is invalid
144
+ logger.warning(f"Invalid message role: {role}, defaulting to 'user'")
145
+ return "user"
146
+
147
+
148
+ # Define a single type for prompt handlers to avoid mypy issues
149
+ PromptHandler = Callable[..., Awaitable[List[Message]]]
150
+
151
+
152
+ def create_prompt_handler(
153
+ template: "PromptTemplate", template_vars: List[str], prompt_files: List[Path]
154
+ ) -> PromptHandler:
155
+ """Create a prompt handler function for the given template"""
156
+ if template_vars:
157
+ # With template variables
158
+ docstring = f"Prompt with template variables: {', '.join(template_vars)}"
159
+
160
+ async def prompt_handler(**kwargs: Any) -> List[Message]:
161
+ # Build context from parameters
162
+ context = {
163
+ var: kwargs.get(var)
164
+ for var in template_vars
165
+ if var in kwargs and kwargs[var] is not None
166
+ }
167
+
168
+ # Apply substitutions to the template
169
+ content_sections = template.apply_substitutions(context)
170
+
171
+ # Convert to MCP Message objects, handling resources properly
172
+ return create_messages_with_resources(content_sections, prompt_files)
173
+ else:
174
+ # No template variables
175
+ docstring = "Get a prompt with no variable substitution"
176
+
177
+ async def prompt_handler(**kwargs: Any) -> List[Message]:
178
+ # Get the content sections
179
+ content_sections = template.content_sections
180
+
181
+ # Convert to MCP Message objects, handling resources properly
182
+ return create_messages_with_resources(content_sections, prompt_files)
183
+
184
+ # Set the docstring
185
+ prompt_handler.__doc__ = docstring
186
+ return prompt_handler
187
+
188
+
189
+ # Type for resource handler
190
+ ResourceHandler = Callable[[], Awaitable[str | bytes]]
191
+
192
+
193
+ def create_resource_handler(resource_path: Path, mime_type: str) -> ResourceHandler:
194
+ """Create a resource handler function for the given resource"""
195
+
196
+ async def get_resource() -> str | bytes:
197
+ is_binary = mime_utils.is_binary_content(mime_type)
198
+
199
+ if is_binary:
200
+ # For binary files, read in binary mode and base64 encode
201
+ with open(resource_path, "rb") as f:
202
+ return f.read()
203
+ else:
204
+ # For text files, read as utf-8 text
205
+ with open(resource_path, "r", encoding="utf-8") as f:
206
+ return f.read()
207
+
208
+ return get_resource
209
+
210
+
211
+ # Default delimiter values
212
+ DEFAULT_USER_DELIMITER = "---USER"
213
+ DEFAULT_ASSISTANT_DELIMITER = "---ASSISTANT"
214
+ DEFAULT_RESOURCE_DELIMITER = "---RESOURCE"
215
+
216
+
217
+ def get_delimiter_config(file_path: Optional[Path] = None) -> Dict[str, Any]:
218
+ """Get delimiter configuration, falling back to defaults if config is None"""
219
+ # Set defaults
220
+ config_values = {
221
+ "user_delimiter": DEFAULT_USER_DELIMITER,
222
+ "assistant_delimiter": DEFAULT_ASSISTANT_DELIMITER,
223
+ "resource_delimiter": DEFAULT_RESOURCE_DELIMITER,
224
+ "prompt_files": [file_path] if file_path else [],
225
+ }
226
+
227
+ # Override with config values if available
228
+ if config is not None:
229
+ config_values["user_delimiter"] = config.user_delimiter
230
+ config_values["assistant_delimiter"] = config.assistant_delimiter
231
+ config_values["resource_delimiter"] = config.resource_delimiter
232
+ config_values["prompt_files"] = config.prompt_files
233
+
234
+ return config_values
235
+
236
+
237
+ def register_prompt(file_path: Path):
238
+ """Register a prompt file"""
239
+ try:
240
+ # Get delimiter configuration
241
+ config_values = get_delimiter_config(file_path)
242
+
243
+ # Use our prompt template loader to analyze the file
244
+ loader = PromptTemplateLoader(
245
+ {
246
+ config_values["user_delimiter"]: "user",
247
+ config_values["assistant_delimiter"]: "assistant",
248
+ config_values["resource_delimiter"]: "resource",
249
+ }
250
+ )
251
+
252
+ # Get metadata and load the template
253
+ metadata = loader.get_metadata(file_path)
254
+ template = loader.load_from_file(file_path)
255
+
256
+ # Ensure unique name
257
+ prompt_name = metadata.name
258
+ if prompt_name in prompt_registry:
259
+ base_name = prompt_name
260
+ suffix = 1
261
+ while prompt_name in prompt_registry:
262
+ prompt_name = f"{base_name}_{suffix}"
263
+ suffix += 1
264
+ metadata.name = prompt_name
265
+
266
+ prompt_registry[metadata.name] = metadata
267
+ logger.info(f"Registered prompt: {metadata.name} ({file_path})")
268
+
269
+ # Create and register prompt handler
270
+ template_vars = list(metadata.template_variables)
271
+ handler = create_prompt_handler(
272
+ template, template_vars, config_values["prompt_files"]
273
+ )
274
+ mcp.prompt(name=metadata.name, description=metadata.description)(handler)
275
+
276
+ # Register any referenced resources in the prompt
277
+ for resource_path in metadata.resource_paths:
278
+ if not resource_path.startswith(("http://", "https://")):
279
+ # It's a local resource
280
+ resource_file = file_path.parent / resource_path
281
+ if resource_file.exists():
282
+ resource_id = f"resource://fast-agent/{resource_file.name}"
283
+
284
+ # Register the resource if not already registered
285
+ if resource_id not in exposed_resources:
286
+ exposed_resources[resource_id] = resource_file
287
+ mime_type = mime_utils.guess_mime_type(str(resource_file))
288
+
289
+ mcp.add_resource(
290
+ FileResource(
291
+ uri=AnyUrl(resource_id),
292
+ path=resource_file,
293
+ mime_type=mime_type,
294
+ is_binary=mime_utils.is_binary_content(mime_type),
295
+ )
296
+ )
297
+
298
+ logger.info(
299
+ f"Registered resource: {resource_id} ({resource_file})"
300
+ )
301
+ except Exception as e:
302
+ logger.error(f"Error registering prompt {file_path}: {e}", exc_info=True)
303
+
304
+
305
+ def parse_args():
306
+ """Parse command line arguments"""
307
+ parser = argparse.ArgumentParser(description="FastMCP Prompt Server")
308
+ parser.add_argument(
309
+ "prompt_files", nargs="+", type=str, help="Prompt files to serve"
310
+ )
311
+ parser.add_argument(
312
+ "--user-delimiter",
313
+ type=str,
314
+ default="---USER",
315
+ help="Delimiter for user messages (default: ---USER)",
316
+ )
317
+ parser.add_argument(
318
+ "--assistant-delimiter",
319
+ type=str,
320
+ default="---ASSISTANT",
321
+ help="Delimiter for assistant messages (default: ---ASSISTANT)",
322
+ )
323
+ parser.add_argument(
324
+ "--resource-delimiter",
325
+ type=str,
326
+ default="---RESOURCE",
327
+ help="Delimiter for resource references (default: ---RESOURCE)",
328
+ )
329
+ parser.add_argument(
330
+ "--http-timeout",
331
+ type=float,
332
+ default=10.0,
333
+ help="Timeout for HTTP requests in seconds (default: 10.0)",
334
+ )
335
+ parser.add_argument(
336
+ "--transport",
337
+ type=str,
338
+ choices=["stdio", "sse"],
339
+ default="stdio",
340
+ help="Transport to use (default: stdio)",
341
+ )
342
+ parser.add_argument(
343
+ "--port",
344
+ type=int,
345
+ default=8000,
346
+ help="Port to use for SSE transport (default: 8000)",
347
+ )
348
+ parser.add_argument(
349
+ "--test", type=str, help="Test a specific prompt without starting the server"
350
+ )
351
+
352
+ return parser.parse_args()
353
+
354
+
355
+ async def register_file_resource_handler():
356
+ """Register the general file resource handler"""
357
+
358
+ @mcp.resource("file://{path}")
359
+ async def get_file_resource(path: str):
360
+ """Read a file from the given path."""
361
+ try:
362
+ # Find the file, checking relative paths first
363
+ file_path = resource_utils.find_resource_file(path, config.prompt_files)
364
+ if file_path is None:
365
+ # If not found as relative path, try absolute path
366
+ file_path = Path(path)
367
+ if not file_path.exists():
368
+ raise FileNotFoundError(f"Resource file not found: {path}")
369
+
370
+ mime_type = mime_utils.guess_mime_type(str(file_path))
371
+ is_binary = mime_utils.is_binary_content(mime_type)
372
+
373
+ if is_binary:
374
+ # For binary files, read as binary and base64 encode
375
+ with open(file_path, "rb") as f:
376
+ return base64.b64encode(f.read()).decode("utf-8")
377
+ else:
378
+ # For text files, read as text with UTF-8 encoding
379
+ with open(file_path, "r", encoding="utf-8") as f:
380
+ return f.read()
381
+ except Exception as e:
382
+ # Log the error and re-raise
383
+ logger.error(f"Error accessing resource at '{path}': {e}")
384
+ raise
385
+
386
+
387
+ async def test_prompt(prompt_name: str) -> int:
388
+ """Test a prompt and print its details"""
389
+ if prompt_name not in prompt_registry:
390
+ logger.error(f"Test prompt not found: {prompt_name}")
391
+ return 1
392
+
393
+ # Get delimiter configuration with reasonable defaults
394
+ config_values = get_delimiter_config()
395
+
396
+ metadata = prompt_registry[prompt_name]
397
+ print(f"\nTesting prompt: {prompt_name}")
398
+ print(f"Description: {metadata.description}")
399
+ print(f"Template variables: {', '.join(metadata.template_variables)}")
400
+
401
+ # Load and print the template
402
+ loader = PromptTemplateLoader(
403
+ {
404
+ config_values["user_delimiter"]: "user",
405
+ config_values["assistant_delimiter"]: "assistant",
406
+ config_values["resource_delimiter"]: "resource",
407
+ }
408
+ )
409
+ template = loader.load_from_file(metadata.file_path)
410
+
411
+ # Print each content section
412
+ print("\nContent sections:")
413
+ for i, section in enumerate(template.content_sections):
414
+ print(f"\n[{i + 1}] Role: {section.role}")
415
+ print(f"Content: {section.text}")
416
+ if section.resources:
417
+ print(f"Resources: {', '.join(section.resources)}")
418
+
419
+ # If there are template variables, test with dummy values
420
+ if metadata.template_variables:
421
+ print("\nTemplate substitution test:")
422
+ test_context = {var: f"[TEST-{var}]" for var in metadata.template_variables}
423
+ applied = template.apply_substitutions(test_context)
424
+
425
+ for i, section in enumerate(applied):
426
+ print(f"\n[{i + 1}] Role: {section.role}")
427
+ print(f"Content with substitutions: {section.text}")
428
+ if section.resources:
429
+ print(f"Resources with substitutions: {', '.join(section.resources)}")
430
+
431
+ return 0
432
+
433
+
434
+ async def async_main():
435
+ """Run the FastMCP server (async version)"""
436
+ global config
437
+
438
+ # Parse command line arguments
439
+ args = parse_args()
440
+
441
+ # Resolve file paths
442
+ prompt_files = []
443
+ for file_path in args.prompt_files:
444
+ path = Path(file_path)
445
+ if not path.exists():
446
+ logger.warning(f"File not found: {path}")
447
+ continue
448
+ prompt_files.append(path.resolve())
449
+
450
+ if not prompt_files:
451
+ logger.error("No valid prompt files specified")
452
+ return 1
453
+
454
+ # Initialize configuration
455
+ config = PromptConfig(
456
+ name="prompt_server",
457
+ description="FastMCP Prompt Server",
458
+ template_variables=set(),
459
+ resource_paths=[],
460
+ file_path=Path(__file__),
461
+ prompt_files=prompt_files,
462
+ user_delimiter=args.user_delimiter,
463
+ assistant_delimiter=args.assistant_delimiter,
464
+ resource_delimiter=args.resource_delimiter,
465
+ http_timeout=args.http_timeout,
466
+ transport=args.transport,
467
+ port=args.port,
468
+ )
469
+
470
+ # Register resource handlers
471
+ await register_file_resource_handler()
472
+
473
+ # Register all prompts
474
+ for file_path in config.prompt_files:
475
+ register_prompt(file_path)
476
+
477
+ # Print startup info
478
+ logger.info("Starting prompt server")
479
+ logger.info(f"Registered {len(prompt_registry)} prompts")
480
+ logger.info(f"Registered {len(exposed_resources)} resources")
481
+ logger.info(
482
+ f"Using delimiters: {config.user_delimiter}, {config.assistant_delimiter}, {config.resource_delimiter}"
483
+ )
484
+
485
+ # If a test prompt was specified, print it and exit
486
+ if args.test:
487
+ return await test_prompt(args.test)
488
+
489
+ # Start the server with the specified transport
490
+ if config.transport == "stdio":
491
+ await mcp.run_stdio_async()
492
+ else: # sse
493
+ await mcp.run_sse_async(port=config.port)
494
+
495
+
496
+ def main() -> int:
497
+ """Run the FastMCP server"""
498
+ try:
499
+ return asyncio.run(async_main())
500
+ except KeyboardInterrupt:
501
+ logger.info("\nServer stopped by user")
502
+ except Exception as e:
503
+ logger.error(f"\nError: {e}", exc_info=True)
504
+ return 1
505
+ return 0
506
+
507
+
508
+ if __name__ == "__main__":
509
+ sys.exit(main())