From f61bb4ee436622d23e223872aa22a7e803bf96d3 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:57:49 +0100 Subject: [PATCH 1/3] Migrate MCP client to SDK v2 protocol models --- .../mcp-2026-07-28-functional-deficits.md | 23 + .../mcp-2026-07-28-impact-assessment.md | 952 ++++++++++++++++++ .../mcp-2026-07-28-real-server-validation.md | 103 ++ examples/a2a/research/server.py | 2 +- examples/data-analysis/analysis-campaign.py | 6 +- examples/mcp/elicitations/blocking_server.py | 4 +- .../elicitations/game_character_handler.py | 4 +- .../elicitations/url_elicitation_server.py | 2 +- .../sampling_tools_server.py | 18 +- .../preview_server.py | 4 +- examples/new-api/display_check.py | 4 +- examples/new-api/textual_markdown_demo.py | 12 +- examples/otel/agent.py | 2 +- examples/otel/agent2.py | 2 +- examples/tensorzero/image_demo.py | 4 +- .../tool-runner-hooks/tool_runner_lowlevel.py | 2 +- .../src/hf_inference_acp/agents.py | 2 +- pyproject.toml | 12 +- src/fast_agent/a2a/content.py | 12 +- src/fast_agent/a2a/remote_agent.py | 2 +- src/fast_agent/a2a/server.py | 13 +- src/fast_agent/acp/acp_context.py | 4 +- src/fast_agent/acp/content_conversion.py | 31 +- src/fast_agent/acp/filesystem_runtime.py | 6 +- src/fast_agent/acp/server/agent_acp_server.py | 2 +- src/fast_agent/acp/server/session_runtime.py | 2 +- src/fast_agent/acp/slash/handlers/tools.py | 2 +- src/fast_agent/acp/terminal_runtime.py | 8 +- src/fast_agent/acp/tool_progress.py | 4 +- src/fast_agent/agents/agent_types.py | 4 +- src/fast_agent/agents/llm_agent.py | 2 +- src/fast_agent/agents/llm_decorator.py | 16 +- src/fast_agent/agents/mcp_agent.py | 44 +- src/fast_agent/agents/smart_agent.py | 6 +- src/fast_agent/agents/tool_agent.py | 16 +- src/fast_agent/agents/tool_call_planning.py | 2 +- src/fast_agent/agents/tool_result_channels.py | 2 +- src/fast_agent/agents/tool_runner.py | 6 +- .../agents/workflow/agents_as_tools_agent.py | 24 +- src/fast_agent/agents/workflow/chain_agent.py | 2 +- .../agents/workflow/iterative_planner.py | 4 +- .../agents/workflow/parallel_agent.py | 2 +- .../agents/workflow/router_agent.py | 2 +- .../cli/checks/structured_tools_probe.py | 6 +- src/fast_agent/cli/commands/auth.py | 17 +- src/fast_agent/cli/runtime/agent_setup.py | 6 +- src/fast_agent/command_actions/models.py | 2 +- .../commands/renderers/tools_markdown.py | 4 +- src/fast_agent/commands/tool_summaries.py | 4 +- src/fast_agent/context.py | 2 - src/fast_agent/core/agent_app.py | 2 +- src/fast_agent/core/agent_card_loader.py | 2 +- src/fast_agent/core/harness.py | 2 +- src/fast_agent/core/harness_app.py | 2 +- src/fast_agent/history/compaction.py | 2 +- .../history/process_poll_folding.py | 2 +- src/fast_agent/history/tool_activities.py | 10 +- src/fast_agent/human_input/simple_form.py | 2 +- src/fast_agent/interfaces.py | 2 +- src/fast_agent/llm/fastagent_llm.py | 8 +- src/fast_agent/llm/internal/passthrough.py | 2 +- .../llm/provider/anthropic/llm_anthropic.py | 16 +- .../multipart_converter_anthropic.py | 18 +- .../llm/provider/bedrock/llm_bedrock.py | 24 +- .../bedrock/multipart_converter_bedrock.py | 6 +- .../llm/provider/google/google_converter.py | 36 +- .../llm/provider/google/llm_google_native.py | 6 +- .../llm/provider/openai/llm_generic.py | 2 +- .../llm/provider/openai/llm_google_oai.py | 2 +- .../llm/provider/openai/llm_openai.py | 24 +- .../provider/openai/llm_tensorzero_openai.py | 2 +- .../openai/multipart_converter_openai.py | 14 +- .../llm/provider/openai/responses.py | 10 +- .../llm/provider/openai/responses_content.py | 6 +- .../llm/provider/openai/responses_output.py | 2 +- .../llm/provider/openai/xai_responses.py | 2 +- .../llm/request_param_resolution.py | 4 +- src/fast_agent/llm/request_params.py | 6 +- src/fast_agent/llm/response_telemetry.py | 2 +- src/fast_agent/llm/retry_telemetry.py | 2 +- src/fast_agent/llm/sampling_converter.py | 18 +- src/fast_agent/mcp/auth/huggingface.py | 2 +- src/fast_agent/mcp/elicitation_handlers.py | 19 +- src/fast_agent/mcp/helpers/content_helpers.py | 12 +- src/fast_agent/mcp/interfaces.py | 44 +- .../mcp/mcp_agent_client_session.py | 320 +++--- src/fast_agent/mcp/mcp_aggregator.py | 74 +- src/fast_agent/mcp/mcp_connection_manager.py | 192 ++-- src/fast_agent/mcp/mcp_content.py | 11 +- src/fast_agent/mcp/oauth_client.py | 6 +- src/fast_agent/mcp/prompt.py | 2 +- src/fast_agent/mcp/prompt_message_extended.py | 2 +- src/fast_agent/mcp/prompt_metadata.py | 2 +- src/fast_agent/mcp/prompt_render.py | 8 +- src/fast_agent/mcp/prompt_serialization.py | 7 +- src/fast_agent/mcp/prompts/prompt_helpers.py | 2 +- src/fast_agent/mcp/prompts/prompt_load.py | 2 +- src/fast_agent/mcp/prompts/prompt_server.py | 6 +- src/fast_agent/mcp/resource_utils.py | 34 +- src/fast_agent/mcp/sampling.py | 23 +- src/fast_agent/mcp/server/harness_adapter.py | 2 +- src/fast_agent/mcp/sse_tracking.py | 315 +----- src/fast_agent/mcp/stdio_tracking_simple.py | 10 +- .../mcp/streamable_http_tracking.py | 506 +--------- src/fast_agent/mcp/tool_execution_handler.py | 2 +- src/fast_agent/mcp/tool_progress.py | 2 +- src/fast_agent/mcp/tool_result_metadata.py | 36 +- src/fast_agent/mcp/tool_result_truncation.py | 4 +- src/fast_agent/mcp/transport_tracking.py | 23 +- src/fast_agent/mcp/ui_mixin.py | 4 +- .../mcp/url_elicitation_required.py | 12 +- src/fast_agent/mcp_server_registry.py | 35 +- src/fast_agent/session/hydrator.py | 6 +- src/fast_agent/session/snapshot.py | 2 +- .../session/token_accounting_validation.py | 2 +- src/fast_agent/session/trace_export_atif.py | 10 +- src/fast_agent/session/trace_export_codex.py | 12 +- src/fast_agent/skills/mcp_registry.py | 17 +- src/fast_agent/tools/apply_patch_tool.py | 4 +- src/fast_agent/tools/attach_media.py | 18 +- .../tools/composite_filesystem_runtime.py | 6 +- src/fast_agent/tools/edit_file_tool.py | 4 +- src/fast_agent/tools/elicitation.py | 6 +- .../tools/environment_filesystem_runtime.py | 2 +- .../tools/external_runtime_protocol.py | 2 +- .../tools/filesystem_runtime_base.py | 10 +- .../tools/filesystem_runtime_protocol.py | 2 +- .../tools/filesystem_tool_definitions.py | 8 +- src/fast_agent/tools/filesystem_tool_specs.py | 2 +- .../tools/local_filesystem_runtime.py | 20 +- src/fast_agent/tools/shell_process.py | 19 +- src/fast_agent/tools/shell_runtime.py | 27 +- .../tools/shell_tool_definitions.py | 14 +- src/fast_agent/tools/skill_reader.py | 6 +- src/fast_agent/tools/tool_sources.py | 2 +- src/fast_agent/types/__init__.py | 2 +- src/fast_agent/types/agent_io.py | 2 +- src/fast_agent/ui/console_display.py | 2 +- src/fast_agent/ui/elicitation_form.py | 2 +- src/fast_agent/ui/history_actions.py | 4 +- src/fast_agent/ui/history_display_rows.py | 4 +- src/fast_agent/ui/interactive_prompt.py | 2 +- src/fast_agent/ui/mcp_display.py | 21 +- src/fast_agent/ui/mcp_ui_utils.py | 7 +- src/fast_agent/ui/message_display_helpers.py | 10 +- src/fast_agent/ui/prompt/completer.py | 10 +- src/fast_agent/ui/prompt/resource_mentions.py | 4 +- .../ui/prompt/status_bar/alert_flags.py | 2 +- .../ui/prompt/status_bar/attachment.py | 2 +- src/fast_agent/ui/terminal_images/renderer.py | 8 +- src/fast_agent/ui/tool_display.py | 64 +- src/fast_agent/workflow_telemetry.py | 2 +- .../e2e/history/test_history_save_load_e2e.py | 6 +- tests/e2e/llm/test_llm_e2e.py | 22 +- tests/e2e/llm/test_responses_e2e.py | 14 +- tests/e2e/llm/test_responses_websocket_e2e.py | 2 +- .../llm/test_responses_websocket_local_e2e.py | 2 +- tests/e2e/multimodal/image_server.py | 7 +- tests/e2e/multimodal/mixed_content_server.py | 6 +- tests/e2e/multimodal/test_gemini_video.py | 7 +- .../e2e/multimodal/test_multimodal_images.py | 2 +- .../test_openai_tool_validation_fix.py | 2 +- tests/e2e/multimodal/video_server.py | 2 +- .../e2e/sampling/sampling_resource_server.py | 2 +- .../a2a/test_fast_agent_a2a_server.py | 17 +- .../a2a/test_remote_agent_runtime.py | 11 +- tests/integration/acp/test_acp_sessions.py | 2 +- .../acp/test_acp_slash_commands.py | 4 +- .../agent_hooks/test_agent_lifecycle_hooks.py | 2 +- tests/integration/api/mcp_dynamic_tools.py | 2 +- tests/integration/api/mcp_progress_server.py | 6 +- tests/integration/api/mcp_tools_server.py | 2 +- tests/integration/api/test_api.py | 13 +- .../api/test_cli_and_mcp_server.py | 7 +- .../api/test_mcp_auth_passthrough.py | 8 +- .../test_parallel_tool_progress_display.py | 2 +- tests/integration/edit/test_edit_file_tool.py | 26 +- .../elicitation_test_server_advanced.py | 4 +- .../elicitation/test_elicitation_handler.py | 13 +- .../test_elicitation_integration.py | 13 +- .../test_url_elicitation_required_error.py | 4 +- .../url_elicitation_required_server.py | 6 +- .../function_tools/test_function_tools.py | 20 +- .../test_file_silent_instruction.py | 8 +- .../mcp_2026/fastagent.config.yaml | 17 + tests/integration/mcp_2026/mrtr_server.py | 35 + .../mcp_2026/subscription_server.py | 25 + .../integration/mcp_2026/test_modern_mrtr.py | 52 + .../mcp_2026/test_modern_subscriptions.py | 51 + .../mcp_ui/test_mcp_ui_integration.py | 7 +- .../test_prompt_server_integration.py | 2 +- .../test_load_prompt_templates.py | 8 +- .../resources/mcp_linked_resouce_server.py | 7 +- .../resources/mcp_resource_template_server.py | 6 +- .../resources/test_resource_api.py | 2 +- tests/integration/roots/root_client.py | 2 +- tests/integration/roots/root_test_server.py | 2 +- .../sampling/sampling_test_server.py | 2 +- .../sampling_tools_test_server.py | 20 +- .../integration/tool_hooks/test_tool_hooks.py | 2 +- tests/integration/tool_loop/test_tool_loop.py | 2 +- .../ui/test_command_dispatch_flows.py | 2 +- tests/scripts/harvest_llm_traces.py | 4 +- .../acp/test_agent_acp_server_sessions.py | 2 +- tests/unit/acp/test_content_conversion.py | 28 +- tests/unit/acp/test_filesystem_runtime.py | 26 +- .../test_filesystem_runtime_integration.py | 24 +- tests/unit/acp/test_terminal_runtime.py | 24 +- tests/unit/acp/test_tool_progress.py | 7 +- .../agents/test_agent_history_binding.py | 2 +- .../fast_agent/agents/test_agent_types.py | 16 +- .../test_llm_agent_streaming_handoff.py | 2 +- .../agents/test_llm_agent_web_metadata.py | 2 +- .../agents/test_llm_content_filter.py | 21 +- .../agents/test_mcp_agent_local_tools.py | 127 +-- .../agents/test_mcp_agent_skills.py | 10 +- .../test_tool_agent_structured_schema.py | 2 +- ...tool_agent_unanswered_tool_call_logging.py | 2 +- .../test_tool_runner_cancelled_history.py | 6 +- .../agents/test_tool_runner_hooks.py | 4 +- .../agents/test_tool_runner_passthrough.py | 6 +- .../workflow/test_agents_as_tools_agent.py | 64 +- .../agents/workflow/test_chain_agent.py | 6 +- .../agents/workflow/test_iterative_planner.py | 2 +- .../agents/workflow/test_router_unit.py | 2 +- .../workflow/test_workflow_request_params.py | 10 +- .../fast_agent/cli/test_session_resume.py | 2 +- .../commands/test_export_command.py | 2 +- .../commands/test_history_summaries.py | 4 +- .../commands/test_runtime_result_export.py | 2 +- .../commands/test_session_handlers_noenv.py | 2 +- .../commands/test_status_markdown.py | 2 +- .../commands/test_tool_summaries.py | 4 +- .../fast_agent/commands/test_tools_handler.py | 6 +- .../fast_agent/core/test_a2a_remote_agent.py | 2 +- tests/unit/fast_agent/core/test_harness.py | 2 +- .../unit/fast_agent/core/test_mcp_content.py | 24 +- tests/unit/fast_agent/core/test_prompt.py | 9 +- .../fast_agent/history/test_compaction.py | 8 +- .../history/test_process_poll_folding.py | 4 +- .../history/test_tool_activities.py | 4 +- .../fast_agent/hooks/test_history_trimmer.py | 4 +- .../fast_agent/hooks/test_session_history.py | 2 +- .../anthropic/test_anthropic_cache_control.py | 2 +- .../anthropic/test_default_headers.py | 230 ++--- .../provider/anthropic/test_file_uploads.py | 19 +- .../test_opentelemetry_compatibility.py | 4 +- .../anthropic/test_reasoning_defaults.py | 22 +- .../anthropic/test_tool_id_sanitization.py | 4 +- .../llm/provider/anthropic/test_web_tools.py | 2 +- .../llm/providers/test_bedrock_converter.py | 8 +- .../llm/providers/test_google_converter.py | 37 +- .../llm/providers/test_google_search.py | 2 +- .../llm/providers/test_google_thinking.py | 2 +- .../llm/providers/test_groq_reasoning.py | 6 +- .../providers/test_llm_anthropic_caching.py | 8 +- .../llm/providers/test_llm_google_vertex.py | 12 +- .../llm/providers/test_llm_openai_history.py | 2 +- .../llm/providers/test_llm_tensorzero_unit.py | 2 +- .../llm/providers/test_metaai_responses.py | 2 +- .../test_multipart_converter_anthropic.py | 109 +- .../test_multipart_converter_google.py | 10 +- .../test_multipart_converter_openai.py | 101 +- .../llm/providers/test_responses_helpers.py | 42 +- .../llm/providers/test_responses_websocket.py | 2 +- .../llm/providers/test_xai_responses.py | 2 +- .../llm/test_cache_control_application.py | 2 +- .../fast_agent/llm/test_clear_behavior.py | 2 +- .../llm/test_max_tokens_acp_regression.py | 38 +- .../fast_agent/llm/test_model_database.py | 16 +- .../fast_agent/llm/test_model_overlays.py | 6 +- .../fast_agent/llm/test_prepare_arguments.py | 12 +- .../llm/test_provider_key_manager_hf.py | 2 +- .../llm/test_request_param_resolution.py | 2 +- .../fast_agent/llm/test_request_params.py | 4 +- .../fast_agent/llm/test_sampling_converter.py | 46 +- tests/unit/fast_agent/llm/test_structured.py | 14 +- .../mcp/prompts/test_prompt_helpers.py | 11 +- .../mcp/prompts/test_prompt_server.py | 2 +- .../mcp/prompts/test_prompt_template.py | 6 +- .../test_template_multipart_integration.py | 2 +- .../fast_agent/mcp/test_content_helpers.py | 28 +- .../mcp/test_elicitation_handlers.py | 6 +- .../fast_agent/mcp/test_harness_app_server.py | 2 +- .../fast_agent/mcp/test_hf_token_verifier.py | 2 +- ...est_mcp_aggregator_metadata_passthrough.py | 29 +- .../mcp/test_mcp_aggregator_nonpersistent.py | 105 +- ...est_mcp_aggregator_resource_completions.py | 6 +- .../mcp/test_mcp_aggregator_runtime_attach.py | 15 +- ...test_mcp_aggregator_server_instructions.py | 4 +- .../mcp/test_mcp_aggregator_skybridge.py | 14 +- .../mcp/test_mcp_prompt_resource_like.py | 7 +- .../mcp/test_oauth_provider_urls.py | 2 +- .../mcp/test_prompt_format_utils.py | 41 +- .../mcp/test_prompt_load_usage_rehydration.py | 2 +- .../mcp/test_prompt_message_multipart.py | 6 +- .../fast_agent/mcp/test_prompt_metadata.py | 2 +- .../mcp/test_prompt_multipart_conversion.py | 2 +- .../unit/fast_agent/mcp/test_prompt_render.py | 12 +- .../mcp/test_prompt_serialization.py | 17 +- tests/unit/fast_agent/mcp/test_sampling.py | 10 +- .../mcp/test_server_session_terminated.py | 8 +- .../unit/fast_agent/mcp/test_sse_tracking.py | 19 - .../mcp/test_streamable_http_tracking.py | 183 ---- .../mcp/test_tool_result_truncation.py | 13 +- .../mcp/test_transport_factory_validation.py | 10 - .../fast_agent/mcp/test_transport_tracking.py | 36 +- tests/unit/fast_agent/mcp/test_ui_mixin.py | 21 +- .../mcp/test_url_elicitation_required.py | 16 +- .../fast_agent/plugins/test_images_plugin.py | 2 +- .../unit/fast_agent/session/test_hydrator.py | 18 +- .../session/test_session_manager.py | 2 +- .../unit/fast_agent/session/test_snapshot.py | 18 +- .../fast_agent/session/test_trace_exporter.py | 65 +- .../fast_agent/skills/test_mcp_registry.py | 43 +- tests/unit/fast_agent/test_a2a_content.py | 9 +- .../test_a2a_remote_agent_events.py | 7 +- tests/unit/fast_agent/test_cli_uri_sources.py | 2 +- .../fast_agent/test_workflow_telemetry.py | 4 +- .../fast_agent/tools/test_apply_patch_tool.py | 2 +- .../test_composite_filesystem_runtime.py | 14 +- .../unit/fast_agent/tools/test_elicitation.py | 4 +- .../test_environment_filesystem_runtime.py | 40 +- .../test_huggingface_sandbox_environment.py | 4 +- .../tools/test_local_filesystem_runtime.py | 72 +- .../tools/test_local_shell_output_spool.py | 2 +- .../tools/test_runtime_tool_sources.py | 8 +- .../fast_agent/tools/test_shell_runtime.py | 116 +-- .../fast_agent/tools/test_tool_sources.py | 8 +- .../types/test_conversation_summary.py | 22 +- .../fast_agent/types/test_message_search.py | 44 +- .../fast_agent/ui/test_agent_completer.py | 16 +- tests/unit/fast_agent/ui/test_agent_info.py | 6 +- .../ui/test_alert_flag_extraction.py | 2 +- .../fast_agent/ui/test_citation_display.py | 2 +- ...onsole_display_empty_additional_message.py | 4 +- .../fast_agent/ui/test_display_suppression.py | 4 +- .../fast_agent/ui/test_elicitation_form.py | 2 +- .../fast_agent/ui/test_history_actions.py | 2 +- .../fast_agent/ui/test_history_display.py | 4 +- .../test_interactive_prompt_agent_commands.py | 4 +- .../ui/test_interactive_prompt_hash_send.py | 4 +- ...st_interactive_prompt_resource_mentions.py | 7 +- tests/unit/fast_agent/ui/test_mcp_ui_utils.py | 7 +- .../ui/test_message_display_helpers.py | 16 +- .../ui/test_read_text_file_tool_display.py | 44 +- .../fast_agent/ui/test_resource_mentions.py | 17 +- .../ui/test_shell_tool_result_display.py | 49 +- .../fast_agent/ui/test_terminal_images.py | 8 +- tests/unit/llm/provider/test_error_utils.py | 2 +- .../scripts/test_validate_token_accounting.py | 4 +- uv.lock | 90 +- 352 files changed, 3728 insertions(+), 3178 deletions(-) create mode 100644 docs-internal/mcp-2026-07-28-functional-deficits.md create mode 100644 docs-internal/mcp-2026-07-28-impact-assessment.md create mode 100644 docs-internal/mcp-2026-07-28-real-server-validation.md create mode 100644 tests/integration/mcp_2026/fastagent.config.yaml create mode 100644 tests/integration/mcp_2026/mrtr_server.py create mode 100644 tests/integration/mcp_2026/subscription_server.py create mode 100644 tests/integration/mcp_2026/test_modern_mrtr.py create mode 100644 tests/integration/mcp_2026/test_modern_subscriptions.py delete mode 100644 tests/unit/fast_agent/mcp/test_sse_tracking.py delete mode 100644 tests/unit/fast_agent/mcp/test_streamable_http_tracking.py diff --git a/docs-internal/mcp-2026-07-28-functional-deficits.md b/docs-internal/mcp-2026-07-28-functional-deficits.md new file mode 100644 index 000000000..0c0161c53 --- /dev/null +++ b/docs-internal/mcp-2026-07-28-functional-deficits.md @@ -0,0 +1,23 @@ +# MCP 2026-07-28 functional-deficit ledger + +This ledger records behavior intentionally not reimplemented during the MCP +SDK v2 / FastMCP v4 migration. Entries remain open until functionality is +restored, replaced, or explicitly removed as a product decision. + +| ID | Area | Deficit | Reason | Recovery | +| --- | --- | --- | --- | --- | +| MCP2-001 | HTTP diagnostics | Exact POST JSON/SSE response mode, GET, and resumption message counters are no longer collected. | SDK v2 exposes no public low-level diagnostics hook; modern MCP also removes GET and resumption. | Keep operation-level status. Add an upstream diagnostics hook only if response-mode telemetry remains valuable. | +| MCP2-002 | Legacy session diagnostics | `Mcp-Session-Id` is not captured from the SDK v2 Streamable HTTP transport. | SDK v2 intentionally removed the session-ID callback from its public transport contract. | Show no session for modern peers. Recover legacy-only IDs if FastMCP/SDK adds a public accessor. | +| MCP2-003 | Modern health | Periodic MCP ping is disabled after modern negotiation. | MCP 2026-07-28 removes `ping`. | Use negotiation, operation success/error, subscription state, and stdio process state. | +| MCP2-004 | HTTP OAuth events | Existing detailed OAuth timing and paste-fallback behavior still depends on the migrated SDK provider subclass. | FastMCP v4's public OAuth API does not expose equivalent event and paste hooks. | Replace the subclass when upstream exposes lifecycle callbacks; retain focused compatibility tests meanwhile. | +| MCP2-005 | Transport result channel | Tool results no longer receive a synthetic `transport_channel` attribute. | Request IDs and response channels are dispatcher internals in SDK v2. | Record operation timing/status externally; request a public diagnostics hook if exact channel attribution is required. | +| MCP2-006 | Negotiation API | Dual-era negotiation imports the SDK's `mcp.client._probe.negotiate_auto`. | SDK v2 implements negotiation centrally but does not export the helper from a public package surface. | Replace the import when the SDK exports negotiation or when fast-agent adopts high-level `mcp.client.Client`. | +| MCP2-007 | MCP OpenTelemetry auto-instrumentation | `opentelemetry-instrumentation-mcp==0.62.1` is not activated. | It patches the removed SDK v1 symbol `mcp.client.streamable_http.streamablehttp_client` and crashes telemetry setup with SDK v2. | Re-enable `McpInstrumentor` when an SDK-v2-compatible release is available; fast-agent's own MCP progress and timing telemetry remains active. | +| MCP3-001 | MRTR low-level API | Fast-agent uses the SDK's `_input_required.run_input_required_driver` with `ClientSession`. | SDK v2 exposes automatic MRTR only on its high-level `Client`; the existing connection manager still owns low-level sessions. | Move the connection boundary to public `mcp.client.Client` once its configured stdio/OAuth adapters can replace the remaining fast-agent lifecycle hooks. | +| MCP3-002 | Response caching | Protocol response caching is not yet enabled on low-level `ClientSession` operations. | SDK v2's cache is integrated with high-level `Client`, not `ClientSession`; duplicating it is undesirable. | Complete the high-level client boundary and use its default per-client in-memory cache. Do not add an aggregator protocol cache. | +| MCP3-003 | Subscription replay | A dropped modern subscription may lose events before reconnection. | MCP subscriptions have no replay. | The supervisor reopens with bounded backoff; perform full derived-index refresh after reconnection when upstream exposes an acknowledgment/restart barrier suitable for this lifecycle. | +| MCP3-004 | Resource subscriptions | The initial supervisor listens for list changes but does not yet maintain a dynamic set of individual resource URIs. | Fast-agent only materializes selected Skybridge resources and the filter is immutable. | Rebuild observed URIs after resource-list refresh and restart the listener with that set. | +| MCP3-005 | Shared cache | No persistent or cross-principal protocol response cache is provided. | Safe sharing requires verified principal partitioning and stable server identity. | Keep future shared caching opt-in and default `share_public=False`. | +| MCP3-006 | FastMCP elicitation examples | FastMCP v4 `Context.elicit()` still uses a standalone server-to-client request, which SDK v2 rejects on a negotiated `2026-07-28` connection. Three existing custom-handler integration cases therefore fail even though the SDK-native MRTR path succeeds. | FastMCP `4.0.0a2` does not expose the SDK `MCPServer` resolver/InputRequired API. | Migrate the examples to SDK `MCPServer` + `Resolve(Elicit(...))`, or adopt the FastMCP MRTR API when one is public. | +| MCP3-007 | State-transfer example | The current harness MCP server exposes agent tools but no `_history` prompt, so `examples/mcp/state-transfer` can send a turn but cannot transfer it through the documented prompt. | The prompt surface was absent before this protocol migration and is not restored by the FastMCP v4 update. | Add a session-scoped history prompt to `HarnessMCPAppServer` and notify prompt-list subscribers when the first history entry is created. | +| MCP3-008 | Modern stdio subscriptions | `subscriptions/listen` is reported as unsupported on modern stdio connections. | SDK v2's server rejects the request with `subscriptions/listen is not served over this transport`; its streaming response contract is implemented for Streamable HTTP. | Retain ordinary stdio list-change notifications if the modern specification adds them, or enable listen when the SDK server transport supports streaming responses over stdio. | diff --git a/docs-internal/mcp-2026-07-28-impact-assessment.md b/docs-internal/mcp-2026-07-28-impact-assessment.md new file mode 100644 index 000000000..01c126df8 --- /dev/null +++ b/docs-internal/mcp-2026-07-28-impact-assessment.md @@ -0,0 +1,952 @@ +# MCP 2026-07-28 impact assessment + +Date: 2026-07-24 + +## Scope and source revisions + +This assessment compares: + +- fast-agent `f848d91a` on `feat/mcp-2026-07-28` +- MCP specification `76346843`, whose draft schema declares + `LATEST_PROTOCOL_VERSION = "2026-07-28"` +- MCP Python SDK v2 at `upstream/main` `00a70148` + (`v2.0.0b2-7-g00a70148`) +- FastMCP v4 at `37fb0ad8` (`v4.0.0a2`) + +The checked-out Python SDK `main` is still based on v1.24 and has local changes. +The v2 assessment therefore uses `upstream/main` and the `v2.0.0b2` tag without +altering the checkout. + +## Executive assessment + +Supporting MCP 2026-07-28 is a protocol-era migration, not a version constant +update. + +The new revision removes protocol initialization and protocol-managed sessions. +Version, identity, and capabilities move to per-request metadata. The Streamable +HTTP transport becomes POST-only and loses `Mcp-Session-Id`, GET notification +streams, DELETE termination, SSE event replay, and `Last-Event-ID`. Server- +initiated requests are replaced by multi round-trip request (MRTR) results. + +This directly conflicts with several central fast-agent assumptions: + +- every connection is established with `ClientSession.initialize()`; +- `InitializeResult` is the source of protocol metadata; +- HTTP sessions and `Mcp-Session-Id` represent durable connection state; +- periodic `ping` determines connection health; +- Streamable HTTP has POST, GET, and resumption channels; +- roots, sampling, and elicitation arrive as server-initiated requests; +- tool-list changes arrive on the general connection receive loop; +- URL elicitation uses `elicitationId`, completion notifications, or `-32042`; +- fast-agent's served MCP agents can use an ambient MCP session as their + application session key. + +The Python SDK v2 and FastMCP v4 already implement dual-era discovery, +negotiation, result validation, MRTR, cache hints, extensions, and modern +transport behavior. Fast-agent should use those implementations and remove its +forks of SDK transport internals rather than porting those forks. + +### Overall impact + +| Area | Impact | Recommendation | +| --- | --- | --- | +| Dependency and protocol types | Very high, mechanical | Upgrade `fastmcp`, `mcp`, and `mcp-types` together; migrate all imports and model access | +| Client connection lifecycle | Very high, architectural | Replace initialize-centric state with SDK/FastMCP dual-era negotiation | +| Custom transports and metrics | Very high, architectural | Delete transport forks; retain a thin fast-agent diagnostics layer | +| Server-side session semantics | Very high, product behavior | Replace ambient MCP session identity with explicit application handles | +| MRTR and elicitation | High | Adopt the SDK/FastMCP driver and remove URL-elicitation compatibility hacks | +| Subscriptions and refresh | High | Use `subscriptions/listen`; retain explicit legacy behavior only when negotiated | +| `/mcp` diagnostics | Medium, high product value | Add negotiated protocol/era and truthful transport topology to `ServerStatus` | +| OAuth | Medium/high | Move to public v2/FastMCP APIs and issuer-keyed credential storage | +| Caching and JSON Schema | Medium | Prefer SDK/FastMCP behavior; validate fast-agent conversion boundaries | +| Deprecated roots/sampling/logging | Medium now, high later | Preserve only for legacy compatibility and publish a migration path | +| Tasks and Apps extensions | Optional | Add after core modern support; do not treat old core Tasks as compatible | + +## Specification delta + +The authoritative summary is +`modelcontextprotocol/docs/specification/draft/changelog.mdx`. + +### Stateless protocol and discovery + +MCP 2026-07-28 removes: + +- `initialize`; +- `notifications/initialized`; +- protocol-level sessions; +- the assumption that capabilities or identity persist from an earlier + exchange. + +Every request now carries: + +- `_meta.io.modelcontextprotocol/protocolVersion`; +- `_meta.io.modelcontextprotocol/clientCapabilities`; +- preferably `_meta.io.modelcontextprotocol/clientInfo`. + +Results should carry +`_meta.io.modelcontextprotocol/serverInfo`. Servers must implement +`server/discover`, which returns supported versions, capabilities, +implementation information, instructions, and cache hints. + +An unsupported version returns `UnsupportedProtocolVersionError` (`-32022`) +with the requested and supported versions. A dual-era client probes a server, +selects a mutually supported modern version, or falls back to the legacy +initialize handshake when the response identifies a legacy server. + +Python SDK v2 implements this policy in: + +- `mcp.client.Client(mode="auto")`; +- `mcp.client._probe.negotiate_auto`; +- `ClientSession.discover()`; +- `ClientSession.adopt()`. + +The negotiated state is already exposed through: + +- `ClientSession.protocol_version`; +- `ClientSession.server_info`; +- `ClientSession.server_capabilities`; +- `ClientSession.instructions`; +- `ClientSession.initialize_result`; +- `ClientSession.discover_result`. + +FastMCP v4 exposes the first four as era-neutral `Client` properties. + +### Required result discriminator and MRTR + +All modern results have `resultType`: + +- `complete` for an ordinary result; +- `input_required` when the server needs client input. + +Clients must treat a missing discriminator from a legacy server as complete. + +MRTR replaces independent server-to-client requests. A tool call, prompt get, +or resource read may return `InputRequiredResult` containing `inputRequests` +and opaque `requestState`. The client obtains the requested input and retries +the original operation with: + +- a new JSON-RPC request ID; +- the exact request state; +- corresponding `inputResponses`. + +This replaces the current push paths for roots, sampling, and elicitation. +Fast-agent should not implement its own retry state machine; SDK v2 and +FastMCP v4 already provide an input-required driver with a configurable round +limit. + +### Streamable HTTP + +Modern Streamable HTTP is: + +- one POST per JSON-RPC request or notification; +- JSON or request-scoped SSE as the POST response; +- no independent GET stream; +- no protocol session ID; +- no DELETE session termination; +- no event IDs, replay, or resumption; +- a new request ID and complete reissue after an interrupted response stream. + +Modern POSTs carry and validate: + +- `MCP-Protocol-Version`; +- `Mcp-Method`; +- `Mcp-Name` for name-bearing operations. + +Tool schemas can use `x-mcp-header` to map primitive tool parameters to +`Mcp-Param-*` headers. Header/body mismatches return `HeaderMismatchError` +(`-32020`). + +This makes the current GET/resumption/session portions of the transport display +legacy-only information. + +### Subscriptions + +`resources/subscribe`, `resources/unsubscribe`, and the HTTP GET notification +stream are replaced by `subscriptions/listen`. + +A client opens a filtered long-lived request for selected event classes: + +- tool-list changes; +- prompt-list changes; +- resource-list changes; +- selected resource URI updates. + +The server acknowledges the subscription and tags feed notifications with the +subscription ID. Request-scoped progress and log notifications remain on the +original request stream. + +Fast-agent currently only reacts directly to +`ToolListChangedNotification`. Modern refresh support needs a subscription +owner that: + +- starts after negotiation; +- requests only needed event classes; +- refreshes tools, prompts, and resources; +- invalidates response caches; +- restarts the subscription after loss without treating the entire server as + disconnected. + +### Caching + +The following complete results now carry required `ttlMs` and `cacheScope`: + +- `server/discover`; +- `tools/list`; +- `prompts/list`; +- `resources/list`; +- `resources/templates/list`; +- `resources/read`. + +`cacheScope` distinguishes public results from results private to an +authorization context. Notifications invalidate related entries. + +FastMCP v4 and SDK v2 provide client response caching. Fast-agent should use +that implementation rather than add another protocol cache to +`MCPAggregator`. Any higher-level aggregate cache must be keyed by server, +negotiated version, operation parameters, and authorization context. + +### Feature changes + +#### Tools, prompts, and resources + +- List results must not vary by connection state. +- Deterministic tool ordering is recommended for prompt-cache stability. +- `tools/call`, `prompts/get`, and `resources/read` can return MRTR. +- Resource not found changes from `-32002` to JSON-RPC Invalid Params + (`-32602`); clients should still accept `-32002` from legacy servers. +- Tool input/output schema accepts general JSON Schema 2020-12. +- `structuredContent` accepts any JSON value, not only an object. +- External `$ref` fetching must not happen implicitly; schema composition needs + resource limits. + +Fast-agent's provider converters and tool-result abstractions currently often +assume object-shaped structured content. Those boundaries need explicit tests +for arrays, scalars, booleans, and null. + +#### Elicitation + +Modern elicitation is MRTR. The revision removes: + +- `elicitationId`; +- `notifications/elicitation/complete`; +- URL elicitation's `-32042` control flow. + +Fast-agent can remove the result/exception payload attachment machinery in +`mcp_agent_client_session.py`, `url_elicitation_required.py`, and related +display tests after the MRTR UI path is complete. Legacy URL elicitation can +remain behind negotiated legacy behavior during the compatibility period. + +#### Roots, sampling, and logging + +Roots, Sampling, and Logging remain available but are deprecated as of +2026-07-28, with earliest removal in a revision released on or after +2027-07-28. + +Recommended migrations are: + +- roots: tool parameters, resource URIs, or server configuration; +- sampling: server-side provider integration; +- logging: stderr for stdio and OpenTelemetry for observability. + +The revision also removes: + +- `ping`; +- `logging/setLevel`; +- `notifications/roots/list_changed`. + +Modern log level is request metadata, and servers cannot send log +notifications for a request that did not opt in. + +Fast-agent should not add new roots/sampling/logging functionality. Existing +support should be explicitly marked legacy/deprecated in config and status +output. + +#### Tasks + +The experimental core Tasks API is removed. Tasks are redesigned as the +optional `io.modelcontextprotocol/tasks` extension: + +- no core `ServerCapabilities.tasks`; +- no `tasks/list`; +- no blocking `tasks/result`; +- polling through `tasks/get`; +- input through `tasks/update`; +- cooperative cancellation; +- a task result type and extension-specific notifications. + +`MCPAggregator.server_supports_feature(..., "tasks")` currently reads +`capabilities.tasks`; that code will fail against v2 types and must be replaced +by extension capability inspection. + +#### Extensions and Apps + +Core capabilities now contain a namespaced `extensions` map. Extensions are +disabled unless both peers advertise compatible support. + +MCP Apps (`io.modelcontextprotocol/ui`) and Tasks should be implemented as +extension adapters, not added to core capability checks. Existing Skybridge +support should be assessed for convergence with the Apps extension rather than +maintained as an unrelated parallel protocol indefinitely. + +### Authorization + +The revision strengthens existing OAuth requirements and deprecates Dynamic +Client Registration in favor of Client ID Metadata Documents. + +Required migration checks include: + +- validate a returned RFC 9207 `iss` before redeeming an authorization code; +- key stored client credentials by authorization-server issuer; +- never reuse credentials after an issuer change; +- specify a suitable `application_type` during DCR fallback; +- preserve resource indicators and audience validation; +- prefer CIMD and retain DCR only as compatibility fallback. + +Fast-agent already supports a client metadata URL, but its custom OAuth client +depends heavily on SDK internals. It should be rebuilt around FastMCP v4's +public `OAuth`/CIMD support or the SDK v2 public auth surface. The browser and +ACP progress UX can remain a fast-agent callback adapter. + +## Python SDK v2 and FastMCP v4 migration + +### Dependency migration + +Current fast-agent pins: + +```toml +fastmcp==3.4.4 +mcp==1.28.1 +``` + +FastMCP v4 currently requires the matching prerelease set: + +```text +fastmcp 4.0.0a2 +fastmcp-slim 4.0.0a2 +mcp 2.0.0b2 +mcp-types 2.0.0b2 +``` + +These must be constrained together until stable releases provide normal +resolver guarantees. The lock update also needs explicit validation of: + +- `opentelemetry-instrumentation-mcp==0.62.1` against SDK v2; +- `httpx2` exception handling and TLS trust-store behavior; +- the remaining `httpx` dependency used by model-provider SDKs; +- FastAPI/Starlette compatibility; +- supported Python versions and packaging extras. + +### Protocol type split and naming + +SDK v2 removes `mcp.types`. Protocol models move to `mcp_types`, and Python +attributes become snake_case while JSON aliases remain camelCase. + +Examples: + +| v1 attribute | v2 attribute | +| --- | --- | +| `inputSchema` | `input_schema` | +| `outputSchema` | `output_schema` | +| `isError` | `is_error` | +| `structuredContent` | `structured_content` | +| `mimeType` | `mime_type` | +| `nextCursor` | `next_cursor` | +| `serverInfo` | `server_info` | +| `protocolVersion` | `protocol_version` | +| `listChanged` | `list_changed` | +| `requestedSchema` | `requested_schema` | + +FastMCP v4 installs a warning compatibility bridge for common reads, but it +does not restore `mcp.types`, old wrappers, private APIs, or all attributes. +Fast-agent should migrate fully and run CI with: + +```text +FASTMCP_MCP_CAMELCASE_COMPAT=false +``` + +Wire-facing serialization must use: + +```python +model.model_dump(by_alias=True, mode="json", exclude_none=True) +``` + +Other important model changes: + +- JSON-RPC and request/notification unions are direct union values, not + `RootModel` objects with `.root`; +- request metadata is a typed mapping rather than the old nested Pydantic + model; +- URI fields are generally strings rather than `AnyUrl`; +- unknown Pydantic fields are not an application metadata channel; +- `McpError` becomes `MCPError`, with keyword/explicit construction; +- list cursors move into `PaginatedRequestParams`; +- session/request timeouts use float seconds. + +This is an architecture-wide mechanical migration because MCP content and tool +types cross agents, providers, ACP, A2A, UI, history, trace export, and local +tool runtimes. + +### FastMCP v4 breaking changes relevant to fast-agent + +- Direct imports from `mcp.types` must move to `mcp_types`. +- Raw `client.session` and `ctx.request_context` expose SDK v2 shapes. +- FastMCP-owned HTTP uses `httpx2`, including its exception types. +- `StreamableHttpTransport(sse_read_timeout=...)` is removed. +- `Client` defaults to `mode="auto"`. +- Resource-not-found is `-32602`. +- Resource templates apply path-traversal screening by default. +- Deprecated proxy/mount/component import paths and several old tool + parameters are removed. + +Fast-agent's current server construction already passes transport settings to +`run_http_async` rather than the `FastMCP` constructor, so that part is aligned. + +## Current fast-agent architecture and direct impact + +### `MCPConnectionManager` + +`src/fast_agent/mcp/mcp_connection_manager.py` owns: + +- transport creation; +- OAuth preparation and escalation; +- long-lived task groups; +- initialization; +- reconnect policy; +- ping health; +- session ID capture; +- server metadata; +- stderr buffering; +- transport metrics. + +Its `ServerConnection.initialize_session()` always calls +`session.initialize()`. It separately stores values that v2 exposes directly +and era-neutrally. + +Required changes: + +1. Replace initialize-only startup with `mode="auto"` negotiation. +2. Store `protocol_version`, era, discovery result, server info, + capabilities, and instructions from public session/client properties. +3. Remove ping health for modern connections. +4. Treat the subscription stream and in-flight request streams independently + from overall server reachability. +5. Remove protocol session termination and session-expiry assumptions on + modern connections. +6. Stop importing SDK-private HTTP factories and transport internals. + +### `MCPAgentClientSession` + +`src/fast_agent/mcp/mcp_agent_client_session.py` subclasses the v1 +`ClientSession` to add: + +- sampling, roots, and elicitation callbacks; +- tool-list change handling; +- URL elicitation compatibility; +- transport response-channel attachment; +- offline/session-expiry translation; +- progress tracing. + +Most of these can become composition: + +- pass supported callbacks to the v2/FastMCP client; +- use the SDK MRTR driver for modern elicitation; +- use extension notification bindings and `subscriptions/listen`; +- consume OpenTelemetry spans or a public diagnostics callback; +- map connection errors at the fast-agent operation boundary. + +Avoid recreating a large v2 `ClientSession` subclass. SDK v2's dispatcher and +callback concurrency differ materially from v1, making inherited internals an +unstable extension point. + +### Transport forks + +The following files copy or subclass SDK transport behavior: + +- `mcp/streamable_http_tracking.py`; +- `mcp/sse_tracking.py`; +- `mcp/stdio_tracking_simple.py`. + +The Streamable HTTP fork depends on exactly the features removed in 2026: + +- GET stream; +- resumption stream; +- SSE event IDs; +- `Last-Event-ID`; +- `Mcp-Session-Id`; +- DELETE termination. + +It also imports private SDK types and uses `httpx`/`httpx-sse`, while SDK v2 +uses `httpx2`. + +Recommendation: + +- delete the Streamable HTTP implementation fork; +- use FastMCP v4 `ClientTransport` or the SDK v2 public transport; +- preserve `TransportChannelMetrics` as a fast-agent product model; +- feed it from public operation/notification callbacks, OpenTelemetry, and + narrow transport event hooks; +- represent legacy-only channels only when the negotiated era actually has + them. + +If exact HTTP response mode remains important, add a small upstream extension +point to FastMCP/SDK rather than inheriting the transport implementation. + +### `MCPAggregator` + +`MCPAggregator` should continue to own fast-agent's namespacing and conversion +of MCP tools/prompts/resources into agent-visible components. It should stop +owning protocol-era behavior already provided upstream. + +Specific changes: + +- source metadata from an era-neutral connection descriptor; +- replace `capabilities.tasks` with extension inspection; +- add prompt/resource refresh handling, not only tool refresh; +- use SDK/FastMCP cache and listen behavior; +- update pagination and structured-content assumptions; +- keep authorization context out of shared cache entries; +- make attach results include negotiation and transport diagnostics. + +### Server registry + +`mcp_server_registry.py` caches `InitializeResult` and always initializes a +temporary session. This should become a cached era-neutral descriptor, such as: + +```python +@dataclass(frozen=True, slots=True) +class MCPPeerInfo: + protocol_version: str + era: Literal["modern", "legacy"] + server_info: Implementation | None + capabilities: ServerCapabilities + instructions: str | None + supported_versions: tuple[str, ...] +``` + +The descriptor should be populated by the connection abstraction rather than +reconstructing negotiation in the registry. + +### Served fast-agent MCP applications + +`HarnessMCPAdapter` currently derives application continuity from +`Mcp-Session-Id`. Documentation explicitly describes that behavior. + +That cannot work in modern MCP. A modern connection has no ambient protocol +session and every request may be independently routed. + +Fast-agent needs separate product semantics: + +- `request`: each call is isolated; +- `shared`: an explicitly configured shared agent instance; +- stateful: a server-minted opaque application handle passed as an ordinary + tool argument. + +FastMCP v4 includes an explicit server session store and `SessionId` tool +argument model for this purpose. The durable design is to expose an operation +that creates a handle and require the handle on subsequent calls. It must be +bound to the authenticated principal and treated as a bearer capability, not a +security boundary by itself. + +Do not silently map modern no-session calls to connection scope. Either: + +- change the modern default to request scope; or +- require the explicit application handle for stateful behavior. + +Legacy clients may continue using ambient `Mcp-Session-Id` during the +compatibility period. + +## `/mcp` and transport display + +### Current state + +There are currently two related command surfaces: + +- `/mcp list` reports attached and configured-but-detached server names; +- `/mcpstatus` (also reached through status handling) renders the detailed + implementation, capability, health, session, and channel display from + `ServerStatus`. + +`ServerStatus` already contains: + +- server implementation name/version; +- server and client capabilities; +- configured transport; +- connection state; +- `Mcp-Session-Id`; +- ping health; +- POST JSON/SSE, GET, resumption, and stdio channel metrics. + +It does not contain the negotiated protocol version or protocol era. + +### Required status model + +Add these fields to the connection status: + +```python +protocol_version: str | None +protocol_era: Literal["modern", "legacy"] | None +supported_protocol_versions: tuple[str, ...] +negotiation: Literal["discover", "initialize", "pinned"] | None +server_implementation_source: Literal["discover", "initialize", "response_meta"] | None +extensions: Mapping[str, object] +transport_endpoint: str | None +transport_process: str | None +transport_security: Literal["none", "bearer", "oauth", "forwarded"] | None +subscription_state: Literal["open", "closed", "unsupported", "error"] | None +``` + +Do not display bearer tokens, OAuth client secrets, complete sensitive headers, +or URL userinfo. Query strings should be redacted by default. + +### Recommended display + +The top metadata section should show: + +```text +Protocol 2026-07-28 (modern, discover) +Server example-server 2.3.1 +Transport Streamable HTTP · https://example.test/mcp +Auth OAuth +Session none (sessionless protocol) +Extensions io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui +``` + +For legacy: + +```text +Protocol 2025-11-25 (legacy, initialize) +Transport Streamable HTTP · session abc…789 +``` + +Transport topology must be negotiated-era aware: + +| Era/transport | Display | +| --- | --- | +| Modern HTTP | request POST JSON/SSE; subscription POST stream; no GET, resumption, protocol session, or ping | +| Legacy Streamable HTTP | POST JSON/SSE; optional GET; optional resumption; optional session ID; ping if enabled | +| Legacy SSE | clearly label deprecated HTTP+SSE | +| stdio modern | process/command, request activity, subscription-listen activity, no protocol ping | +| stdio legacy | process/command, request activity, legacy ping | + +The existing channel panel should not render removed channels as merely idle; +that incorrectly suggests they are available. Mark them `n/a (modern)` or omit +them. + +Modern health should be evidence-based: + +- last successful operation; +- active subscription state; +- last transport error; +- process state for stdio; +- optional explicit reconnect/probe initiated by the user. + +It should not send removed `ping` requests. An HTTP endpoint is not a durable +connection, so “connected” should mean “negotiated and currently usable,” not +“an HTTP socket or MCP session is open.” + +### Command UX recommendation + +Unify the simple and detailed views: + +- `/mcp` or `/mcp list`: concise rows including server, state, protocol, and + transport; +- `/mcp status [server]` or `/mcp list --verbose`: current detailed display; +- retain `/mcpstatus` as a compatibility alias. + +This prevents negotiated protocol information from being hidden behind a +separate command. + +## Refactor target + +### Proposed boundaries + +```mermaid +flowchart LR + Config[MCPServerSettings] --> Factory[MCP client factory] + Factory --> Client[FastMCP v4 Client / SDK v2 Client] + Client --> Peer[MCPPeerInfo] + Client --> Ops[MCP operations] + Client --> Events[subscription + progress events] + Client --> Diag[diagnostic events] + Peer --> Aggregator[MCPAggregator] + Ops --> Aggregator + Events --> Aggregator + Diag --> Status[ServerStatus] + Aggregator --> Agents[Agent tool/prompt/resource surfaces] + Status --> UI[/mcp display] +``` + +The central connection abstraction should expose product capabilities, not +transport streams: + +```python +class MCPClientConnection(Protocol): + @property + def peer_info(self) -> MCPPeerInfo: ... + + @property + def transport_info(self) -> MCPTransportInfo: ... + + async def list_tools(self, ...) -> ListToolsResult: ... + async def call_tool(self, ...) -> CallToolResult: ... + async def listen(self, ...) -> AsyncIterator[MCPServerEvent]: ... + async def close(self) -> None: ... +``` + +Fast-agent can then change from FastMCP `Client` to the lower-level SDK client +without exposing that choice to the aggregator or UI. + +### Code to delete or substantially reduce + +Once equivalent behavior is covered: + +- most of `streamable_http_tracking.py`; +- the SDK-copying portions of `sse_tracking.py`; +- stream adaptation helpers and session-ID callbacks in + `mcp_connection_manager.py`; +- manual initialize metadata duplication; +- ping protocol and config for modern connections; +- GET/resumption metrics for modern connections; +- URL elicitation exception/result payload attachment; +- direct `ClientSession` inheritance used only for notification interception; +- old core Tasks capability logic; +- protocol-session-based server instance leasing for modern calls. + +Keep: + +- fast-agent's target parsing and configuration UX; +- OAuth browser/progress presentation; +- attach/detach/reconnect orchestration; +- aggregator namespacing and agent-visible conversion; +- stderr capture for stdio; +- the product-level diagnostics/status model; +- operation-level retry policy where it is not already protocol negotiation. + +## Implementation plan + +### Phase 0: dependency spike and migration gates + +Impact: high, bounded. + +1. Create a coordinated prerelease constraint set. +2. Verify OpenTelemetry MCP instrumentation against SDK v2. +3. Run import collection and type checking before behavior changes. +4. Add CI bans for: + - `from mcp.types`; + - `mcp.shared._httpx_utils`; + - `GetSessionIdCallback`; + - `streamablehttp_client`; + - direct JSON-RPC `.root` access; + - camelCase protocol attribute reads. +5. Run FastMCP with the camelCase bridge disabled. + +Exit criterion: the dependency set resolves and failures are categorized. + +### Phase 1: mechanical SDK v2 port + +Impact: very high, mostly mechanical. + +1. Move protocol imports to `mcp_types`. +2. Convert Python field access to snake_case. +3. Update union handling, errors, metadata, pagination, URI boundaries, and + timeouts. +4. Make wire serialization alias-explicit. +5. Update provider, ACP, A2A, history, UI, and trace conversion tests. + +Exit criterion: fast-agent imports, lints, and type-checks on v2 without relying +on FastMCP compatibility shims. + +### Phase 2: replace transport and connection lifecycle + +Impact: very high, architectural. + +1. Introduce `MCPPeerInfo` and `MCPTransportInfo`. +2. Wrap FastMCP v4 `Client` or SDK v2 `Client` behind the connection protocol. +3. Use `mode="auto"` by default and expose an explicit legacy override only + for compatibility. +4. Remove the Streamable HTTP fork. +5. Retain narrow diagnostics hooks and stdio stderr capture. +6. Replace ping health with era-aware health. + +Exit criterion: stdio and Streamable HTTP work against modern, legacy, and +dual-era simulators with correct metadata. + +### Phase 3: MRTR, subscriptions, and caching + +Impact: high. + +1. Route `InputRequiredResult` through existing human-input/elicitation UI. +2. Support repeated MRTR rounds and cancellation. +3. Remove modern URL-elicitation hacks. +4. Own one `subscriptions/listen` lifecycle per attached server as needed. +5. Refresh tools, prompts, resources, and templates. +6. Enable upstream cache handling and verify private cache isolation. + +Exit criterion: modern elicitation and list/resource updates work without +server-initiated requests or GET streams. + +### Phase 4: served fast-agent semantics + +Impact: very high, product decision required. + +1. Make modern request scope explicit. +2. Add server-minted application session handles for stateful agent use. +3. Bind handles to authenticated principals. +4. Preserve ambient MCP session behavior only for negotiated legacy clients. +5. Update `instance_scope` validation and documentation. + +Exit criterion: no modern server behavior depends on connection affinity or +`Mcp-Session-Id`. + +### Phase 5: `/mcp`, extensions, and deprecation + +Impact: medium. + +1. Add protocol/era/negotiation and transport endpoint details to status. +2. Render modern and legacy channel topology truthfully. +3. Replace Tasks core checks with extension checks. +4. Evaluate Tasks and Apps as separate optional deliverables. +5. Mark roots, sampling, logging, SSE, and DCR compatibility as deprecated. + +Exit criterion: users can diagnose exactly what was negotiated and what +transport behavior is active. + +## Test strategy + +Prefer simulators and contract tests over mocks of SDK internals. + +### Required matrix + +| Dimension | Cases | +| --- | --- | +| Server era | modern-only, legacy-only, dual-era | +| Negotiation | discover success, unsupported-version retry, legacy fallback, no mutual version | +| Transport | stdio, modern HTTP JSON, modern HTTP SSE, legacy Streamable HTTP, deprecated SSE | +| Auth | none, bearer, OAuth, CIMD, DCR fallback, issuer change | +| Features | tools, prompts, resources, pagination, structured content, progress, MRTR, subscriptions | +| Failure | malformed result, interrupted stream, 401 escalation, 400 era fallback, subscription loss | + +### Protocol invariants + +- Every modern request has protocol version, client capabilities, and HTTP + protocol headers where applicable. +- Every modern complete result has `resultType`, `ttlMs`, and `cacheScope` + where required. +- No modern request uses initialization, ping, protocol sessions, GET + notification streams, DELETE termination, or resumption. +- MRTR retries use a new JSON-RPC ID and preserve exact request state. +- Subscription notifications are filtered and correlated. +- Private cache entries do not cross authorization contexts. +- `serverInfo` is display-only and never drives a security decision. +- OAuth authorization response issuer is validated. +- JSON Schema external references are not fetched implicitly. + +### fast-agent behavior contracts + +- `/mcp` reports the negotiated version and era. +- Modern HTTP does not display GET/resumption/session/ping as active. +- Legacy HTTP still reports its actual session and channels. +- Redaction prevents credentials and URL userinfo from entering status output. +- Tool/prompt/resource refresh does not interrupt unrelated in-flight calls. +- Modern served agents preserve state only with explicit application handles. +- Legacy roots/sampling/elicitation remain isolated to negotiated legacy mode. + +### Existing suites requiring early attention + +- `tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py`; +- `tests/unit/fast_agent/mcp/test_harness_app_server.py`; +- `tests/unit/fast_agent/ui/test_mcp_display.py`; +- integration tests under sampling, roots, elicitation, resources, and OAuth; +- provider converter tests using MCP content/tool models; +- ACP/A2A content conversion and trace export tests. + +Use SDK v2's in-process client/server path for focused contracts and real stdio +or ASGI transport simulators for transport behavior. Do not patch copied SDK +private methods into tests; those are the implementation being removed. + +## Documentation and configuration impact + +At minimum update: + +- `docs/docs/mcp/mcp-server.md`; +- `docs/docs/mcp/harness-adapter.md`; +- `docs/docs/mcp/huggingface-spaces.md`; +- `docs/docs/mcp/mcp_display.md`; +- generated configuration references; +- `examples/setup/fast-agent.yaml`; +- MCP server/client examples. + +Documentation must stop presenting `Mcp-Session-Id` as a universal application +session, distinguish protocol era from configured transport, and explain the +legacy-only status of roots, sampling, logging, SSE, DCR, and ping settings. + +Configuration should add an explicit protocol mode only if operationally +needed: + +```yaml +mcp: + servers: + example: + protocol_mode: auto # auto | legacy | 2026-07-28 +``` + +`auto` should be the default. A forced legacy setting is a migration escape +hatch, not the long-term solution for stateful servers. + +Legacy-only settings such as ping interval and SSE read timeout should either +be grouped under a compatibility section or accepted with a warning when a +modern connection is negotiated. + +## Risks and open decisions + +### Product decisions + +1. What should `instance_scope="connection"` mean for modern MCP? + Recommendation: reject it or require an explicit application session + handle; do not emulate connection affinity. +2. Should `/mcp list` become the concise status surface? + Recommendation: yes, while retaining `/mcpstatus` as an alias. +3. Should fast-agent adopt Tasks in the first compatibility release? + Recommendation: no; ship core modern support first. +4. Should Skybridge converge on MCP Apps? + Recommendation: assess after core extension negotiation is in place. +5. How long should forced legacy roots/sampling/elicitation remain supported? + Recommendation: publish a removal plan aligned with MCP's earliest removal + revision. + +### Engineering risks + +- private SDK transport dependencies make a line-by-line port fragile; +- `httpx` and `httpx2` exception classes can coexist and silently bypass the + wrong handler; +- FastMCP's compatibility bridge can hide incomplete snake_case migration; +- authorization-scoped cache mistakes can leak data; +- direct MCP types are used far outside `fast_agent.mcp`; +- OpenTelemetry instrumentation may lag SDK v2; +- modern no-session behavior can silently lose conversation continuity if the + served-agent design is not changed first; +- pre-existing local Python SDK changes must not be mistaken for v2 behavior. + +## Recommendation + +Proceed with the migration, but make refactoring the connection boundary part +of the first implementation milestone. + +The least safe approach is to update imports and then port +`streamable_http_tracking.py` until tests pass. That would preserve an +obsolete transport model and continue depending on private SDK internals. + +The preferred approach is: + +1. mechanically adopt `mcp_types` and snake_case; +2. introduce era-neutral peer and transport descriptors; +3. delegate negotiation, transport, MRTR, subscriptions, caching, and + extensions to SDK v2/FastMCP v4; +4. retain fast-agent ownership of aggregation, UI, auth presentation, and + product lifecycle; +5. redesign served-agent continuity around explicit application handles; +6. make `/mcp` the authoritative display of negotiated protocol and actual + transport topology. + +This is a large migration, but it should leave fast-agent with materially less +protocol code and a more stable boundary for future MCP revisions. diff --git a/docs-internal/mcp-2026-07-28-real-server-validation.md b/docs-internal/mcp-2026-07-28-real-server-validation.md new file mode 100644 index 000000000..3fc3118c0 --- /dev/null +++ b/docs-internal/mcp-2026-07-28-real-server-validation.md @@ -0,0 +1,103 @@ +# MCP 2026-07-28 real-server validation + +Validated on 24 July 2026 from branch `feat/mcp-2026-07-28`. + +## Official server-everything + +Source: + +- repository: `modelcontextprotocol/servers` +- commit: `d31124c982401739917fd817c2a59db344529c16` +- package: `@modelcontextprotocol/server-everything@2.0.0` +- SDK dependency: `@modelcontextprotocol/sdk@^1.29.0` + +The checkout was built outside the fast-agent tree: + +```bash +git clone --depth 1 https://github.com/modelcontextprotocol/servers.git \ + /tmp/modelcontextprotocol-servers +cd /tmp/modelcontextprotocol-servers/src/everything +npm install +npm run build +``` + +The current fast-agent client passed the same operation probe over stdio and +Streamable HTTP: + +- 19 tools discovered; +- 7 resources discovered; +- 4 prompts discovered; +- `echo` and `get-sum` calls; +- structured tool output; +- dynamic resource read; +- prompt retrieval; +- legacy server-initiated form elicitation through a deterministic callback; +- legacy sampling through the passthrough model callback. + +Both transports correctly negotiated the real server as: + +```text +protocol_version=2025-11-25 +protocol_era=legacy +negotiation=initialize +``` + +## SDK v2 reference server + +An SDK `MCPServer` fixture negotiated: + +```text +protocol_version=2026-07-28 +protocol_era=modern +supported_protocol_versions=(2026-07-28,) +negotiation=discover +``` + +Verified behaviors: + +- tool, prompt, and resource operations; +- no legacy ping activity; +- SDK-native multi-round-trip elicitation using + `Resolve(Elicit(...))`; +- automatic client callback dispatch and retry to a complete tool result; +- HTTP `subscriptions/listen`; +- tool-list change delivery and fast-agent cache refresh. + +The external `server-everything` and Hugging Face checks above are manual +network/process probes. Durable SDK-v2 integration coverage under +`tests/integration/mcp_2026/` covers modern negotiation, tool/prompt/resource +operations, no legacy ping activity, MRTR, and HTTP tool-list subscriptions. + +## Hugging Face MCP + +Anonymous discovery against `https://huggingface.co/mcp` succeeded when run +with an isolated `HF_HOME` so a developer credential could not affect the +probe: + +```bash +HF_HOME=/tmp/empty-hf-home uv run --project . python /tmp/fa-hf-probe/probe.py +``` + +Observed: + +```text +server=@huggingface/mcp-services 0.3.35 +protocol_version=2025-11-25 +protocol_era=legacy +negotiation=initialize +``` + +Anonymous `tools/list` returned the public Hugging Face tool surface. A local +cached Hugging Face token is automatically forwarded by fast-agent; an invalid +cached token can make this probe fail, which is why the validation isolates +`HF_HOME`. + +## Existing examples + +The elicitation integration suite currently passes 4 of 7 selected cases. +The three custom-handler failures are the FastMCP standalone-elicitation +deficit recorded as `MCP3-006`. A separate SDK-native MRTR probe passes. + +The state-transfer example successfully exposes and calls `agent_one`, but +does not expose the documented `agent_one_history` prompt. This is recorded as +`MCP3-007`. diff --git a/examples/a2a/research/server.py b/examples/a2a/research/server.py index db23877a9..94c213b88 100644 --- a/examples/a2a/research/server.py +++ b/examples/a2a/research/server.py @@ -47,7 +47,7 @@ from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue - from mcp.types import ContentBlock + from mcp_types import ContentBlock from fast_agent.tools.execution_environment import ShellEnvironment from fast_agent.types import AgentResponse diff --git a/examples/data-analysis/analysis-campaign.py b/examples/data-analysis/analysis-campaign.py index 15cfc17e6..fd092f1d5 100644 --- a/examples/data-analysis/analysis-campaign.py +++ b/examples/data-analysis/analysis-campaign.py @@ -19,7 +19,7 @@ Extract key insights that would be compelling for a social media campaign. """, servers=["interpreter"], - request_params=RequestParams(maxTokens=8192), + request_params=RequestParams(max_tokens=8192), model="sonnet", ) @fast.agent( @@ -32,7 +32,7 @@ - Has had its findings challenged, and justified - Extracted compelling insights suitable for social media promotion """, - request_params=RequestParams(maxTokens=8192), + request_params=RequestParams(max_tokens=8192), model="gpt-4.1", ) @fast.evaluator_optimizer( @@ -169,7 +169,7 @@ "translate_campaign", ], model="sonnet", # Using a more capable model for orchestration - request_params=RequestParams(maxTokens=8192), + request_params=RequestParams(max_tokens=8192), plan_type="full", ) async def main() -> None: diff --git a/examples/mcp/elicitations/blocking_server.py b/examples/mcp/elicitations/blocking_server.py index 81f6ea92b..87d51b707 100644 --- a/examples/mcp/elicitations/blocking_server.py +++ b/examples/mcp/elicitations/blocking_server.py @@ -33,7 +33,7 @@ from pydantic import BaseModel, Field if TYPE_CHECKING: - from mcp.types import ElicitResult + from mcp_types import ElicitResult # Configure detailed logging logging.basicConfig( @@ -131,7 +131,7 @@ async def deploy_dissociated() -> str: result: ElicitResult = await session.elicit_form( message="Confirm deployment configuration (dissociated - via GET stream)", - requestedSchema=requested_schema, + requested_schema=requested_schema, related_request_id=None, # <-- KEY: No related_request_id = routes to GET stream ) diff --git a/examples/mcp/elicitations/game_character_handler.py b/examples/mcp/elicitations/game_character_handler.py index 96c5e39ff..08e54a8f5 100644 --- a/examples/mcp/elicitations/game_character_handler.py +++ b/examples/mcp/elicitations/game_character_handler.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast from mcp.shared.context import RequestContext -from mcp.types import ElicitRequestParams, ElicitResult +from mcp_types import ElicitRequestParams, ElicitResult from rich.console import Console from rich.progress import BarColumn, Progress, TextColumn from rich.prompt import Confirm @@ -33,7 +33,7 @@ async def game_character_elicitation_handler( """Custom handler that creates an interactive character creation experience.""" logger.info(f"Game character elicitation handler called: {params.message}") - requested_schema = getattr(params, "requestedSchema", None) + requested_schema = getattr(params, "requested_schema", None) if requested_schema: properties = requested_schema.get("properties", {}) content: dict[str, Any] = {} diff --git a/examples/mcp/elicitations/url_elicitation_server.py b/examples/mcp/elicitations/url_elicitation_server.py index 1d90ea356..572b57fa1 100644 --- a/examples/mcp/elicitations/url_elicitation_server.py +++ b/examples/mcp/elicitations/url_elicitation_server.py @@ -16,7 +16,7 @@ from fastmcp import FastMCP from fastmcp.server.dependencies import get_context -from mcp.types import ElicitResult +from mcp_types import ElicitResult # Configure logging logging.basicConfig( diff --git a/examples/mcp/sampling-with-tools/sampling_tools_server.py b/examples/mcp/sampling-with-tools/sampling_tools_server.py index 65aa4874f..000ee6abe 100644 --- a/examples/mcp/sampling-with-tools/sampling_tools_server.py +++ b/examples/mcp/sampling-with-tools/sampling_tools_server.py @@ -12,7 +12,7 @@ from fastmcp import Context, FastMCP from fastmcp.tools import ToolResult -from mcp.types import ( +from mcp_types import ( SamplingMessage, TextContent, Tool, @@ -35,7 +35,7 @@ Tool( name="add", description="Add two numbers together", - inputSchema={ + input_schema={ "type": "object", "properties": { "a": {"type": "number", "description": "First number"}, @@ -47,7 +47,7 @@ Tool( name="subtract", description="Subtract second number from first", - inputSchema={ + input_schema={ "type": "object", "properties": { "a": {"type": "number", "description": "First number"}, @@ -59,7 +59,7 @@ Tool( name="multiply", description="Multiply two numbers together", - inputSchema={ + input_schema={ "type": "object", "properties": { "a": {"type": "number", "description": "First number"}, @@ -71,7 +71,7 @@ Tool( name="divide", description="Divide first number by second", - inputSchema={ + input_schema={ "type": "object", "properties": { "a": {"type": "number", "description": "Numerator"}, @@ -87,7 +87,7 @@ SECRET_CODE_TOOL = Tool( name="get_secret", description="Returns a secret code. You must call this tool to get the secret.", - inputSchema={ + input_schema={ "type": "object", "properties": {}, "required": [], @@ -128,10 +128,10 @@ async def fetch_secret(ctx: Context) -> ToolResult: tool_choice=ToolChoice(mode="required"), # Force the LLM to use the tool ) - logger.info(f"Received response with stopReason: {result.stopReason}") + logger.info(f"Received response with stopReason: {result.stop_reason}") # If LLM wants to use the tool - if result.stopReason == "toolUse": + if result.stop_reason == "toolUse": tool_uses = [] if isinstance(result.content, list): tool_uses = [c for c in result.content if isinstance(c, ToolUseContent)] @@ -143,7 +143,7 @@ async def fetch_secret(ctx: Context) -> ToolResult: tool_results = [ ToolResultContent( type="tool_result", - toolUseId=tu.id, + tool_use_id=tu.id, content=[TextContent(type="text", text=f"SECRET: {SECRET_CODE}")], ) for tu in tool_uses diff --git a/examples/mcp/structured-content-preview/preview_server.py b/examples/mcp/structured-content-preview/preview_server.py index e6584ec94..df919fd46 100644 --- a/examples/mcp/structured-content-preview/preview_server.py +++ b/examples/mcp/structured-content-preview/preview_server.py @@ -10,7 +10,7 @@ from typing import Any from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent app = FastMCP(name="Structured Content Preview Demo") @@ -25,7 +25,7 @@ def _text_block(payload: Any) -> TextContent: def _tool_result(*, text_payloads: list[Any], structured_payload: dict[str, Any]) -> CallToolResult: result = CallToolResult( content=[_text_block(payload) for payload in text_payloads], - isError=False, + is_error=False, ) setattr(result, "structuredContent", structured_payload) return result diff --git a/examples/new-api/display_check.py b/examples/new-api/display_check.py index a8940cd1b..bd981e663 100644 --- a/examples/new-api/display_check.py +++ b/examples/new-api/display_check.py @@ -14,10 +14,10 @@ async def main(): agent: LlmAgent = LlmAgent(test, context=core.context) await agent.attach_llm(ModelFactory.create_factory("haiku")) await agent.send("hello world, render some xml tags both inside and outside of code fences") - await agent.generate("write a 200 word story", RequestParams(maxTokens=50)) + await agent.generate("write a 200 word story", RequestParams(max_tokens=50)) await agent.generate( "repeat after me: `one, two, three, four`", - RequestParams(stopSequences=[" two,"]), + RequestParams(stop_sequences=[" two,"]), ) diff --git a/examples/new-api/textual_markdown_demo.py b/examples/new-api/textual_markdown_demo.py index 43296af75..cef1587a5 100644 --- a/examples/new-api/textual_markdown_demo.py +++ b/examples/new-api/textual_markdown_demo.py @@ -25,7 +25,7 @@ from fast_agent.ui.message_primitives import MESSAGE_CONFIGS, MessageType if TYPE_CHECKING: - from mcp.types import CallToolResult + from mcp_types import CallToolResult from fast_agent.interfaces import AgentProtocol from fast_agent.llm.stream_types import StreamChunk @@ -760,7 +760,7 @@ def handle_display_tool_result( content_blocks.append(text) content = "\n\n".join(content_blocks) if content_blocks else "_No content returned._" - structured = getattr(result, "structuredContent", None) + structured = getattr(result, "structured_content", None) if structured is not None: try: structured_text = json.dumps(structured, indent=2) @@ -768,7 +768,7 @@ def handle_display_tool_result( structured_text = str(structured) content += f"\n\n```json\n{structured_text}\n```" - status = "ERROR" if result.isError else "success" + status = "ERROR" if result.is_error else "success" right_info = f"tool result - {status}" bottom_metadata: list[str] = [] @@ -784,9 +784,9 @@ def handle_display_tool_result( name=agent_name or "Tool", right_info=right_info, bottom_metadata=bottom_metadata or None, - highlight_color_override="red" if result.isError else None, - block_color_override="red" if result.isError else None, - arrow_style_override="dim red" if result.isError else None, + highlight_color_override="red" if result.is_error else None, + block_color_override="red" if result.is_error else None, + arrow_style_override="dim red" if result.is_error else None, ) self._messages.append(message) self._refresh_chat() diff --git a/examples/otel/agent.py b/examples/otel/agent.py index 5b000aed4..05290844c 100644 --- a/examples/otel/agent.py +++ b/examples/otel/agent.py @@ -23,7 +23,7 @@ class FormattedResponse(BaseModel): name="chat", instruction="You are a helpful AI Agent", servers=["fetch"], - request_params=RequestParams(maxTokens=8192), + request_params=RequestParams(max_tokens=8192), ) async def main(): # use the --model command line switch or agent arguments to change model diff --git a/examples/otel/agent2.py b/examples/otel/agent2.py index 105379f74..22a575ebd 100644 --- a/examples/otel/agent2.py +++ b/examples/otel/agent2.py @@ -23,7 +23,7 @@ class FormattedResponse(BaseModel): name="chat", instruction="You are a helpful AI Agent", servers=["fetch"], - request_params=RequestParams(maxTokens=8192), + request_params=RequestParams(max_tokens=8192), ) async def main(): # use the --model command line switch or agent arguments to change model diff --git a/examples/tensorzero/image_demo.py b/examples/tensorzero/image_demo.py index 0debd1c66..fd06336e5 100644 --- a/examples/tensorzero/image_demo.py +++ b/examples/tensorzero/image_demo.py @@ -3,7 +3,7 @@ import mimetypes from pathlib import Path -from mcp.types import ImageContent, TextContent +from mcp_types import ImageContent, TextContent from fast_agent import FastAgent from fast_agent.llm.request_params import RequestParams @@ -54,7 +54,7 @@ async def main(): image_bytes = image_file.read() encoded_data = base64.b64encode(image_bytes).decode("utf-8") - content_parts.append(ImageContent(type="image", mimeType=mime_type, data=encoded_data)) + content_parts.append(ImageContent(type="image", mime_type=mime_type, data=encoded_data)) message = Prompt.user(*content_parts) async with fast.run() as agent_app: diff --git a/examples/tool-runner-hooks/tool_runner_lowlevel.py b/examples/tool-runner-hooks/tool_runner_lowlevel.py index 974828133..ed1440fb4 100644 --- a/examples/tool-runner-hooks/tool_runner_lowlevel.py +++ b/examples/tool-runner-hooks/tool_runner_lowlevel.py @@ -1,6 +1,6 @@ import asyncio -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent diff --git a/publish/hf-inference-acp/src/hf_inference_acp/agents.py b/publish/hf-inference-acp/src/hf_inference_acp/agents.py index 5f29aacf0..c62521fce 100644 --- a/publish/hf-inference-acp/src/hf_inference_acp/agents.py +++ b/publish/hf-inference-acp/src/hf_inference_acp/agents.py @@ -749,7 +749,7 @@ async def apply_model(self, model: str) -> None: self.config.model = new_model if self.config.default_request_params is not None: params_without_model = self.config.default_request_params.model_dump( - exclude={"model", "maxTokens"} + exclude={"model", "max_tokens"} ) self.config.default_request_params = RequestParams(**params_without_model) diff --git a/pyproject.toml b/pyproject.toml index c2cb208cb..718018ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,9 @@ classifiers = [ requires-python = ">=3.12,<3.15" dependencies = [ "fastapi==0.136.3", - "fastmcp==3.4.4", - "mcp==1.28.1", + "fastmcp==4.0.0a2", + "mcp==2.0.0b2", + "mcp-types==2.0.0b2", "pydantic-settings==2.14.2", "pydantic==2.13.4", "pyyaml==6.0.3", @@ -190,6 +191,13 @@ ignore=["E501"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["ANN"] # Don't require type annotations in tests +[tool.uv] +constraint-dependencies = [ + "fastmcp-slim==4.0.0a2", + "mcp==2.0.0b2", + "mcp-types==2.0.0b2", +] + [tool.uv.workspace] members = [ "publish/fast-agent-acp", diff --git a/src/fast_agent/a2a/content.py b/src/fast_agent/a2a/content.py index b041aa3de..679dda9c8 100644 --- a/src/fast_agent/a2a/content.py +++ b/src/fast_agent/a2a/content.py @@ -9,7 +9,7 @@ from a2a.types import Part from google.protobuf.json_format import ParseDict -from mcp.types import ( +from mcp_types import ( AudioContent, BlobResourceContents, ContentBlock, @@ -28,12 +28,12 @@ def part_from_content(content: ContentBlock) -> Part | None: if isinstance(content, ImageContent | AudioContent): return Part( raw=base64.b64decode(content.data), - media_type=content.mimeType, + media_type=content.mime_type, ) if isinstance(content, ResourceLink): return Part( url=str(content.uri), - media_type=content.mimeType or "", + media_type=content.mime_type or "", filename=content.name, ) if not isinstance(content, EmbeddedResource): @@ -43,16 +43,16 @@ def part_from_content(content: ContentBlock) -> Part | None: if isinstance(resource, BlobResourceContents): return Part( raw=base64.b64decode(resource.blob), - media_type=resource.mimeType or "", + media_type=resource.mime_type or "", filename=filename_from_uri(str(resource.uri)), ) if isinstance(resource, TextResourceContents): - data_part = json_data_part(resource.text, media_type=resource.mimeType) + data_part = json_data_part(resource.text, media_type=resource.mime_type) if data_part is not None: return data_part return Part( text=resource.text, - media_type=resource.mimeType or "text/plain", + media_type=resource.mime_type or "text/plain", filename=filename_from_uri(str(resource.uri)), ) return None diff --git a/src/fast_agent/a2a/remote_agent.py b/src/fast_agent/a2a/remote_agent.py index 4a996ed9d..f401141a3 100644 --- a/src/fast_agent/a2a/remote_agent.py +++ b/src/fast_agent/a2a/remote_agent.py @@ -12,7 +12,7 @@ from a2a.client import A2ACardResolver, ClientConfig, create_client from a2a.types import Message, Part, Role, SendMessageRequest, TaskState from google.protobuf.json_format import MessageToDict -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.a2a.content import part_from_content from fast_agent.agents.agent_types import AgentConfig, AgentType diff --git a/src/fast_agent/a2a/server.py b/src/fast_agent/a2a/server.py index bdfe645c0..b655743f7 100644 --- a/src/fast_agent/a2a/server.py +++ b/src/fast_agent/a2a/server.py @@ -35,14 +35,13 @@ ) from fastapi import FastAPI from google.protobuf.json_format import MessageToDict -from mcp.types import ( +from mcp_types import ( BlobResourceContents, EmbeddedResource, ImageContent, ResourceLink, TextContent, ) -from pydantic import AnyUrl from starlette.responses import JSONResponse from fast_agent.a2a.content import part_from_content @@ -854,8 +853,8 @@ def _content_from_part(part: Part) -> list[Any]: ResourceLink( type="resource_link", name=label, - uri=AnyUrl(part.url), - mimeType=part.media_type or None, + uri=part.url, + mime_type=part.media_type or None, ) ] except ValueError: @@ -863,14 +862,14 @@ def _content_from_part(part: Part) -> list[Any]: if part.HasField("raw"): data = base64.b64encode(part.raw).decode("ascii") if part.media_type.startswith("image/"): - return [ImageContent(type="image", data=data, mimeType=part.media_type)] + return [ImageContent(type="image", data=data, mime_type=part.media_type)] label = part.filename or "attachment" return [ EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl(f"attachment:///{quote(label)}"), - mimeType=part.media_type or "application/octet-stream", + uri=f"attachment:///{quote(label)}", + mime_type=part.media_type or "application/octet-stream", blob=data, ), ) diff --git a/src/fast_agent/acp/acp_context.py b/src/fast_agent/acp/acp_context.py index 5b6c4defe..bed473839 100644 --- a/src/fast_agent/acp/acp_context.py +++ b/src/fast_agent/acp/acp_context.py @@ -54,7 +54,7 @@ class ClientCapabilities: terminal: bool = False fs_read: bool = False fs_write: bool = False - _meta: dict[str, Any] = field(default_factory=dict) + meta: dict[str, Any] = field(default_factory=dict) @classmethod def from_acp_capabilities(cls, caps: ACPClientCapabilities | None) -> "ClientCapabilities": @@ -68,7 +68,7 @@ def from_acp_capabilities(cls, caps: ACPClientCapabilities | None) -> "ClientCap terminal=bool(caps.terminal), fs_read=bool(fs_caps.read_text_file) if fs_caps else False, fs_write=bool(fs_caps.write_text_file) if fs_caps else False, - _meta=dict(meta) if isinstance(meta, dict) else {}, + meta=dict(meta) if isinstance(meta, dict) else {}, ) diff --git a/src/fast_agent/acp/content_conversion.py b/src/fast_agent/acp/content_conversion.py index dbf0ec8fd..1a73d0595 100644 --- a/src/fast_agent/acp/content_conversion.py +++ b/src/fast_agent/acp/content_conversion.py @@ -11,7 +11,7 @@ from urllib.parse import urlparse import acp.schema as acp_schema -import mcp.types as mcp_types +import mcp_types as mcp_types from acp.helpers import ( ContentBlock as ACPContentBlock, ) @@ -24,8 +24,7 @@ resource_link_block, text_block, ) -from mcp.types import ContentBlock as MCPContentBlock -from pydantic import AnyUrl +from mcp_types import ContentBlock as MCPContentBlock from fast_agent.io.path_uri import file_uri_to_path from fast_agent.utils.commandline import quote_commandline_token @@ -81,7 +80,7 @@ def _convert_image_content( return mcp_types.ImageContent( type="image", data=acp_image.data, - mimeType=acp_image.mime_type, + mime_type=acp_image.mime_type, annotations=_convert_annotations(acp_image.annotations), ) @@ -93,7 +92,7 @@ def _convert_audio_content( return mcp_types.AudioContent( type="audio", data=acp_audio.data, - mimeType=acp_audio.mime_type, + mime_type=acp_audio.mime_type, annotations=_convert_annotations(acp_audio.annotations), ) @@ -105,8 +104,8 @@ def _convert_resource_link( return mcp_types.ResourceLink( type="resource_link", name=acp_link.name, - uri=AnyUrl(acp_link.uri), - mimeType=acp_link.mime_type, + uri=acp_link.uri, + mime_type=acp_link.mime_type, size=acp_link.size, description=acp_link.description, title=acp_link.title, @@ -132,14 +131,14 @@ def _convert_resource_contents( match acp_resource: case acp_schema.TextResourceContents(): return mcp_types.TextResourceContents( - uri=AnyUrl(acp_resource.uri), - mimeType=acp_resource.mime_type or None, + uri=acp_resource.uri, + mime_type=acp_resource.mime_type or None, text=acp_resource.text, ) case acp_schema.BlobResourceContents(): return mcp_types.BlobResourceContents( - uri=AnyUrl(acp_resource.uri), - mimeType=acp_resource.mime_type or None, + uri=acp_resource.uri, + mime_type=acp_resource.mime_type or None, blob=acp_resource.blob, ) case _: @@ -385,14 +384,14 @@ def convert_mcp_content_to_acp(mcp_content: MCPContentBlock) -> ACPContentBlock case mcp_types.TextContent(): return text_block(mcp_content.text) case mcp_types.ImageContent(): - return image_block(mcp_content.data, mcp_content.mimeType) + return image_block(mcp_content.data, mcp_content.mime_type) case mcp_types.AudioContent(): - return audio_block(mcp_content.data, mcp_content.mimeType) + return audio_block(mcp_content.data, mcp_content.mime_type) case mcp_types.ResourceLink(): return resource_link_block( name=mcp_content.name, uri=str(mcp_content.uri), - mime_type=mcp_content.mimeType, + mime_type=mcp_content.mime_type, size=mcp_content.size, description=mcp_content.description, title=mcp_content.title, @@ -411,13 +410,13 @@ def _convert_mcp_embedded_resource_to_acp( embedded = embedded_text_resource( uri=str(mcp_content.resource.uri), text=mcp_content.resource.text, - mime_type=mcp_content.resource.mimeType, + mime_type=mcp_content.resource.mime_type, ) case mcp_types.BlobResourceContents(): embedded = embedded_blob_resource( uri=str(mcp_content.resource.uri), blob=mcp_content.resource.blob, - mime_type=mcp_content.resource.mimeType, + mime_type=mcp_content.resource.mime_type, ) case _: return None diff --git a/src/fast_agent/acp/filesystem_runtime.py b/src/fast_agent/acp/filesystem_runtime.py index 24e786859..35eabc58b 100644 --- a/src/fast_agent/acp/filesystem_runtime.py +++ b/src/fast_agent/acp/filesystem_runtime.py @@ -12,7 +12,7 @@ from acp.helpers import tool_diff_content from acp.schema import ToolCallProgress -from mcp.types import CallToolResult, Tool +from mcp_types import CallToolResult, Tool from fast_agent.core.logging.logger import get_logger from fast_agent.mcp.helpers.content_helpers import text_content @@ -50,7 +50,7 @@ def _error_result(message: str) -> CallToolResult: - return CallToolResult(content=[text_content(message)], isError=True) + return CallToolResult(content=[text_content(message)], is_error=True) def _fatal_error_result(message: str) -> CallToolResult: @@ -59,7 +59,7 @@ def _fatal_error_result(message: str) -> CallToolResult: def _success_result(message: str) -> CallToolResult: - return CallToolResult(content=[text_content(message)], isError=False) + return CallToolResult(content=[text_content(message)], is_error=False) def _write_display_arguments(path: str, content: str) -> dict[str, Any]: diff --git a/src/fast_agent/acp/server/agent_acp_server.py b/src/fast_agent/acp/server/agent_acp_server.py index 6b867365c..2caa6302a 100644 --- a/src/fast_agent/acp/server/agent_acp_server.py +++ b/src/fast_agent/acp/server/agent_acp_server.py @@ -370,7 +370,7 @@ async def initialize( logger.info( "ACP initialize response sent", name="acp_initialize_response", - protocol_version=response.protocolVersion, + protocol_version=response.protocol_version, ) return response diff --git a/src/fast_agent/acp/server/session_runtime.py b/src/fast_agent/acp/server/session_runtime.py index 276f7c885..50cd1df57 100644 --- a/src/fast_agent/acp/server/session_runtime.py +++ b/src/fast_agent/acp/server/session_runtime.py @@ -524,7 +524,7 @@ async def build_session_request_params( return None if session_state is not None: session_state.resolved_instructions[agent_name] = resolved - return RequestParams(systemPrompt=resolved) + return RequestParams(system_prompt=resolved) async def resolve_instruction_for_session( self, diff --git a/src/fast_agent/acp/slash/handlers/tools.py b/src/fast_agent/acp/slash/handlers/tools.py index 3e0d0802e..3f43bd2ea 100644 --- a/src/fast_agent/acp/slash/handlers/tools.py +++ b/src/fast_agent/acp/slash/handlers/tools.py @@ -12,7 +12,7 @@ from fast_agent.interfaces import AgentProtocol if TYPE_CHECKING: - from mcp.types import ListToolsResult + from mcp_types import ListToolsResult from fast_agent.acp.slash_commands import SlashCommandHandler diff --git a/src/fast_agent/acp/terminal_runtime.py b/src/fast_agent/acp/terminal_runtime.py index 055807df9..a1992757e 100644 --- a/src/fast_agent/acp/terminal_runtime.py +++ b/src/fast_agent/acp/terminal_runtime.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from mcp.types import CallToolResult, Tool +from mcp_types import CallToolResult, Tool from fast_agent.constants import DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT, TERMINAL_BYTES_PER_TOKEN from fast_agent.core.logging.logger import get_logger @@ -81,7 +81,7 @@ class _TerminalOutputResult: def _error_result(message: str) -> CallToolResult: - return CallToolResult(content=[text_content(message)], isError=True) + return CallToolResult(content=[text_content(message)], is_error=True) def _needs_shell_wrapper(command: str) -> bool: @@ -284,7 +284,7 @@ def __init__( Tool( name=EXECUTE_TOOL_NAME, description="Execute a shell command.", - inputSchema={ + input_schema={ "type": "object", "properties": { "command": { @@ -415,7 +415,7 @@ async def execute( result = CallToolResult( content=[text_content(result_text)], - isError=is_error, + is_error=is_error, ) await self._notify_tool_complete( diff --git a/src/fast_agent/acp/tool_progress.py b/src/fast_agent/acp/tool_progress.py index a549b6043..c04a42d10 100644 --- a/src/fast_agent/acp/tool_progress.py +++ b/src/fast_agent/acp/tool_progress.py @@ -19,8 +19,8 @@ text_block, tool_content, ) -from mcp.types import ContentBlock as MCPContentBlock -from mcp.types import TextContent +from mcp_types import ContentBlock as MCPContentBlock +from mcp_types import TextContent from fast_agent.acp.content_conversion import convert_mcp_content_to_acp from fast_agent.acp.tool_call_context import get_acp_tool_call_meta diff --git a/src/fast_agent/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index 2e818ed4d..fbf96ae60 100644 --- a/src/fast_agent/agents/agent_types.py +++ b/src/fast_agent/agents/agent_types.py @@ -138,10 +138,10 @@ def __post_init__(self): raise AgentConfigError("save_trajectory requires use_history=False") if self.default_request_params is None: self.default_request_params = RequestParams( - use_history=self.use_history, systemPrompt=self.instruction + use_history=self.use_history, system_prompt=self.instruction ) else: # Override the request params history setting if explicitly configured self.default_request_params.use_history = self.use_history # Ensure instruction takes precedence over any existing systemPrompt - self.default_request_params.systemPrompt = self.instruction + self.default_request_params.system_prompt = self.instruction diff --git a/src/fast_agent/agents/llm_agent.py b/src/fast_agent/agents/llm_agent.py index fad4886df..877124b00 100644 --- a/src/fast_agent/agents/llm_agent.py +++ b/src/fast_agent/agents/llm_agent.py @@ -16,7 +16,7 @@ from a2a.types import AgentCapabilities from mcp import Tool -from mcp.types import ContentBlock +from mcp_types import ContentBlock from rich.text import Text from fast_agent.agents.agent_types import AgentConfig diff --git a/src/fast_agent/agents/llm_decorator.py b/src/fast_agent/agents/llm_decorator.py index b6e63ff9f..094a9c499 100644 --- a/src/fast_agent/agents/llm_decorator.py +++ b/src/fast_agent/agents/llm_decorator.py @@ -27,7 +27,7 @@ from a2a.types import AgentCard from mcp import ListToolsResult, Tool -from mcp.types import ( +from mcp_types import ( CallToolResult, ContentBlock, EmbeddedResource, @@ -319,10 +319,10 @@ def set_instruction(self, instruction: str) -> None: """Set the agent's instruction/system prompt.""" self._instruction = instruction if self._default_request_params: - self._default_request_params.systemPrompt = instruction + self._default_request_params.system_prompt = instruction if self._llm is not None: self._llm.instruction = instruction - self._llm.default_request_params.systemPrompt = instruction + self._llm.default_request_params.system_prompt = instruction async def set_model(self, model: str | None) -> None: """Set the default model for this agent and reattach the LLM if needed.""" @@ -985,7 +985,7 @@ def _sanitize_message_for_llm( updated_result = tool_result.model_copy(update={"content": filtered_blocks}) except AttributeError: updated_result = CallToolResult( - content=filtered_blocks, isError=getattr(tool_result, "isError", False) + content=filtered_blocks, is_error=getattr(tool_result, "is_error", False) ) else: updated_result = tool_result @@ -1081,7 +1081,7 @@ def _extract_block_metadata(self, block: ContentBlock) -> tuple[str | None, str] return self._embedded_resource_metadata(block) if isinstance(block, ResourceLink): - mime = getattr(block, "mimeType", None) + mime = getattr(block, "mime_type", None) return mime, self._category_from_mime(mime) return None, "document" @@ -1094,11 +1094,11 @@ def _direct_block_metadata( return "text/plain", "text" if isinstance(block, TextResourceContents): - mime = getattr(block, "mimeType", None) or "text/plain" + mime = getattr(block, "mime_type", None) or "text/plain" return mime, "text" if isinstance(block, ImageContent): - mime = getattr(block, "mimeType", None) or "image/*" + mime = getattr(block, "mime_type", None) or "image/*" return mime, "vision" return None @@ -1108,7 +1108,7 @@ def _embedded_resource_metadata( block: EmbeddedResource, ) -> tuple[str | None, str]: resource = getattr(block, "resource", None) - mime = getattr(resource, "mimeType", None) + mime = getattr(resource, "mime_type", None) if isinstance(resource, TextResourceContents): return mime or "text/plain", "text" return mime, self._category_from_mime(mime) diff --git a/src/fast_agent/agents/mcp_agent.py b/src/fast_agent/agents/mcp_agent.py index 3b553a711..a25205dac 100644 --- a/src/fast_agent/agents/mcp_agent.py +++ b/src/fast_agent/agents/mcp_agent.py @@ -20,12 +20,11 @@ Any, Literal, TypeVar, - cast, ) -import mcp +import mcp_types from a2a.types import AgentCard, AgentSkill -from mcp.types import ( +from mcp_types import ( CallToolResult, ContentBlock, EmbeddedResource, @@ -81,6 +80,11 @@ build_provider_managed_mcp_state, split_managed_server_names, ) +from fast_agent.mcp.tool_result_metadata import ( + ToolResultDisplayMetadata, + tool_result_display_metadata, + update_tool_result_display_metadata, +) from fast_agent.mcp.tool_result_truncation import truncate_tool_result_for_llm from fast_agent.skills import SKILLS_DEFAULT, SkillManifest from fast_agent.skills.registry import SkillRegistry @@ -1432,19 +1436,19 @@ async def _call_human_input_tool( resp_text = await run_elicitation_form(arguments or {}, agent_name=self._name) if resp_text == "__DECLINED__": return CallToolResult( - isError=False, + is_error=False, content=[TextContent(type="text", text="The Human declined the input request")], ) if resp_text in ("__CANCELLED__", "__DISABLE_SERVER__"): return CallToolResult( - isError=False, + is_error=False, content=[ TextContent(type="text", text="The Human cancelled the input request") ], ) # Success path: return the (JSON) response as-is return CallToolResult( - isError=False, + is_error=False, content=[TextContent(type="text", text=resp_text)], ) @@ -1452,7 +1456,7 @@ async def _call_human_input_tool( raise except asyncio.TimeoutError as e: return CallToolResult( - isError=True, + is_error=True, content=[ TextContent( type="text", @@ -1465,7 +1469,7 @@ async def _call_human_input_tool( print(f"Error in _call_human_input_tool: {traceback.format_exc()}") return CallToolResult( - isError=True, + is_error=True, content=[TextContent(type="text", text=f"Error requesting human input: {e!s}")], ) @@ -1827,7 +1831,7 @@ async def _run_parallel_planned_tool_calls( self.logger.error(f"MCP tool {call.display_tool_name} failed: {item}") result = CallToolResult( content=[TextContent(type="text", text=f"Error: {item!s}")], - isError=True, + is_error=True, ) duration_ms = 0.0 else: @@ -1868,7 +1872,7 @@ async def _run_sequential_planned_tool_calls( self.logger.error(f"MCP tool {call.display_tool_name} failed: {e}") error_result = CallToolResult( content=[TextContent(type="text", text=f"Error: {e!s}")], - isError=True, + is_error=True, ) tool_results[call.correlation_id] = error_result self.display.show_tool_result( @@ -1913,16 +1917,14 @@ async def _record_planned_tool_result( display_tool_name=call.display_tool_name, tool_args=call.tool_args, ) - result_meta = cast("Any", result) - result_meta.tool_name = call.display_tool_name - tool_results[call.correlation_id] = result + display_metadata = tool_result_display_metadata(result) tool_timings[call.correlation_id] = ToolTimingInfo( timing_ms=duration_ms, - transport_channel=getattr(result, "transport_channel", None), + transport_channel=display_metadata.get("transport_channel"), ) - if getattr(result, "_suppress_display", False): + if display_metadata.get("suppress_display", False): return result_tool_call_id = None @@ -2271,10 +2273,12 @@ def _attach_read_text_file_display_metadata( line_value = positive_int_or_none(tool_args.get("line")) limit_value = positive_int_or_none(tool_args.get("limit")) - result_meta = cast("Any", result) - result_meta.read_text_file_path = stripped - result_meta.read_text_file_line = line_value - result_meta.read_text_file_limit = limit_value + metadata: ToolResultDisplayMetadata = {"read_text_file_path": stripped} + if line_value is not None: + metadata["read_text_file_line"] = line_value + if limit_value is not None: + metadata["read_text_file_limit"] = limit_value + update_tool_result_display_metadata(result, metadata) @classmethod def _tool_result_type_label(cls, display_tool_name: str) -> str | None: @@ -2342,7 +2346,7 @@ async def apply_prompt_messages( async def list_prompts( self, namespace: str | None = None, server_name: str | None = None - ) -> Mapping[str, list[mcp.types.Prompt]]: + ) -> Mapping[str, list[mcp_types.Prompt]]: """ List all prompts available to this agent, filtered by configuration. diff --git a/src/fast_agent/agents/smart_agent.py b/src/fast_agent/agents/smart_agent.py index 5d8411f32..328317ae3 100644 --- a/src/fast_agent/agents/smart_agent.py +++ b/src/fast_agent/agents/smart_agent.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable -from mcp.types import BlobResourceContents, ReadResourceResult, TextResourceContents +from mcp_types import BlobResourceContents, ReadResourceResult, TextResourceContents from fast_agent.agents.agent_types import AgentConfig, AgentType from fast_agent.agents.mcp_agent import McpAgent @@ -1241,7 +1241,7 @@ def _extract_read_resource_text(result: ReadResourceResult, *, max_chars: int = for idx, content in enumerate(result.contents, start=1): if isinstance(content, TextResourceContents): text = content.text - lines.append(f"[{idx}] text ({content.mimeType or 'unknown'})") + lines.append(f"[{idx}] text ({content.mime_type or 'unknown'})") lines.append(text) continue @@ -1253,7 +1253,7 @@ def _extract_read_resource_text(result: ReadResourceResult, *, max_chars: int = preview = decoded[:400].decode("utf-8", errors="replace") except Exception: preview = "" - lines.append(f"[{idx}] blob ({content.mimeType or 'unknown'}, {blob_len} b64 chars)") + lines.append(f"[{idx}] blob ({content.mime_type or 'unknown'}, {blob_len} b64 chars)") if preview: lines.append(preview) continue diff --git a/src/fast_agent/agents/tool_agent.py b/src/fast_agent/agents/tool_agent.py index fd891db10..64db3203d 100644 --- a/src/fast_agent/agents/tool_agent.py +++ b/src/fast_agent/agents/tool_agent.py @@ -5,7 +5,7 @@ from typing import Any from fastmcp.tools import FunctionTool, ToolResult -from mcp.types import CallToolResult, ContentBlock, ListToolsResult, Tool +from mcp_types import CallToolResult, ContentBlock, ListToolsResult, Tool from fast_agent.agents.agent_types import AgentConfig, AgentType from fast_agent.agents.llm_agent import LlmAgent @@ -131,7 +131,7 @@ def add_tool(self, tool: FunctionTool, *, replace: bool = True) -> None: Tool( name=tool.name, description=tool.description, - inputSchema=tool.parameters, + input_schema=tool.parameters, ) ) @@ -696,7 +696,7 @@ async def run_one( if isinstance(item, BaseException): result = CallToolResult( content=[text_content(f"Error: {item!s}")], - isError=True, + is_error=True, ) duration_ms = 0.0 else: @@ -827,7 +827,7 @@ def _mark_tool_loop_error( ) -> str: error_result = CallToolResult( content=[text_content(error_message)], - isError=True, + is_error=True, ) tool_results[correlation_id] = error_result self.display.show_tool_result( @@ -872,7 +872,7 @@ async def call_tool( logger.warning(f"Unknown tool: {name}") return CallToolResult( content=[text_content(f"Unknown tool: {name}")], - isError=True, + is_error=True, ) tool_handler = self._get_tool_handler(request_params) @@ -902,7 +902,7 @@ async def call_tool( logger.error(f"Tool {name} failed: {e}") tool_result = CallToolResult( content=[text_content(f"Error: {e!s}")], - isError=True, + is_error=True, ) payload = url_elicitation_required_payload(e) if payload is not None: @@ -933,7 +933,7 @@ def _get_tool_handler( def _native_tool_result_to_mcp_result(result: ToolResult) -> CallToolResult: return CallToolResult( content=result.content, - structuredContent=result.structured_content, + structured_content=result.structured_content, _meta=result.meta, - isError=False, + is_error=False, ) diff --git a/src/fast_agent/agents/tool_call_planning.py b/src/fast_agent/agents/tool_call_planning.py index 1e3fb856f..b9772c451 100644 --- a/src/fast_agent/agents/tool_call_planning.py +++ b/src/fast_agent/agents/tool_call_planning.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any -from mcp.types import CallToolResult +from mcp_types import CallToolResult from fast_agent.types import RequestParams diff --git a/src/fast_agent/agents/tool_result_channels.py b/src/fast_agent/agents/tool_result_channels.py index 295438233..09b84f21f 100644 --- a/src/fast_agent/agents/tool_result_channels.py +++ b/src/fast_agent/agents/tool_result_channels.py @@ -6,7 +6,7 @@ from dataclasses import asdict from typing import TYPE_CHECKING -from mcp.types import CallToolResult, ContentBlock, TextContent +from mcp_types import CallToolResult, ContentBlock, TextContent from fast_agent.constants import ( FAST_AGENT_ERROR_CHANNEL, diff --git a/src/fast_agent/agents/tool_runner.py b/src/fast_agent/agents/tool_runner.py index 5c3244db3..9b175ee08 100644 --- a/src/fast_agent/agents/tool_runner.py +++ b/src/fast_agent/agents/tool_runner.py @@ -10,7 +10,7 @@ cast, ) -from mcp.types import CallToolResult, ContentBlock, ListToolsResult, TextContent +from mcp_types import CallToolResult, ContentBlock, ListToolsResult, TextContent from fast_agent.constants import ( DEFAULT_MAX_ITERATIONS, @@ -565,7 +565,7 @@ def _build_interrupted_tool_result( for tool_id in pending_request.tool_calls or {}: tool_results[tool_id] = CallToolResult( content=[text_content(interrupted_text)], - isError=True, + is_error=True, ) return PromptMessageExtended( @@ -581,7 +581,7 @@ def _build_tool_error_response( for tool_id in request.tool_calls or {}: tool_results[tool_id] = CallToolResult( content=[text_content(error_message)], - isError=True, + is_error=True, ) channels = {FAST_AGENT_ERROR_CHANNEL: [text_content(error_message)]} diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 54f74dd73..930f3bd7f 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -198,7 +198,7 @@ async def coordinator(): pass from typing import TYPE_CHECKING, Any, Callable, Literal, assert_never from mcp import ListToolsResult, Tool -from mcp.types import CallToolResult +from mcp_types import CallToolResult from fast_agent.acp.tool_call_context import acp_tool_call_context from fast_agent.agents.mcp_agent import McpAgent @@ -559,7 +559,7 @@ async def list_tools(self) -> ListToolsResult: Tool( name=tool_name, description=description, - inputSchema=input_schema, + input_schema=input_schema, ) ) existing_names.add(tool_name) @@ -683,7 +683,7 @@ def _child_tool_result_from_response( return ( CallToolResult( content=content_blocks, - isError=bool(error_blocks), + is_error=bool(error_blocks), ), error_blocks, ) @@ -699,7 +699,7 @@ async def _complete_child_tool_call( if not tool_handler or not tool_call_id: return try: - if tool_result.isError: + if tool_result.is_error: error_text = get_text(error_blocks[0]) if error_blocks else None with acp_tool_call_context(): await tool_handler.on_tool_complete( @@ -892,7 +892,7 @@ async def _invoke_child_agent( if response_mode_control.error is not None: return CallToolResult( content=[text_content(response_mode_control.error)], - isError=True, + is_error=True, ) child_request_params = self._build_child_request_params( request_params, @@ -990,7 +990,7 @@ async def emit_progress() -> None: tool_call_id=tool_call_id, error=str(exc), ) - return CallToolResult(content=[text_content(f"Error: {exc}")], isError=True) + return CallToolResult(content=[text_content(f"Error: {exc}")], is_error=True) finally: if hooks_set and isinstance(child, ToolRunnerHookCapable): child.tool_runner_hooks = hook_install.previous_hooks @@ -1052,7 +1052,7 @@ async def _build_child_tool_run_plan( error_message = f"Tool '{tool_name}' is not available" tool_results[correlation_id] = CallToolResult( - content=[text_content(error_message)], isError=True + content=[text_content(error_message)], is_error=True ) tool_loop_error = tool_loop_error or error_message descriptor.status = "error" @@ -1078,7 +1078,7 @@ async def _run_child_tool_clone( child = self._resolve_child_agent(tool_name) if not child: error_msg = f"Unknown agent-tool: {tool_name}" - return CallToolResult(content=[text_content(error_msg)], isError=True) + return CallToolResult(content=[text_content(error_msg)], is_error=True) instance_name = f"{child.name}[{instance}]" try: @@ -1092,7 +1092,7 @@ async def _run_child_tool_clone( "error": str(exc), }, ) - return CallToolResult(content=[text_content(f"Spawn failed: {exc}")], isError=True) + return CallToolResult(content=[text_content(f"Spawn failed: {exc}")], is_error=True) fork_index = self._load_history_into_clone(child, clone, instance_name) progress_started = self._start_child_clone_progress( @@ -1446,7 +1446,7 @@ def _merge_child_tool_execution_results( if isinstance(result, BaseException): msg = f"Tool execution failed: {result}" plan.tool_results[correlation_id] = CallToolResult( - content=[text_content(msg)], isError=True + content=[text_content(msg)], is_error=True ) plan.tool_loop_error = plan.tool_loop_error or msg descriptor.status = "error" @@ -1454,7 +1454,7 @@ def _merge_child_tool_execution_results( continue plan.tool_results[correlation_id] = result - descriptor.status = "error" if result.isError else "done" + descriptor.status = "error" if result.is_error else "done" @staticmethod def _ordered_child_tool_records( @@ -1609,7 +1609,7 @@ def _show_parallel_tool_results( show_hook_indicator=self.has_external_hooks, result=CallToolResult( content=[text_content(f"{collapsed} more results (collapsed)")], - isError=False, + is_error=False, ), ) diff --git a/src/fast_agent/agents/workflow/chain_agent.py b/src/fast_agent/agents/workflow/chain_agent.py index f56066497..2916dee36 100644 --- a/src/fast_agent/agents/workflow/chain_agent.py +++ b/src/fast_agent/agents/workflow/chain_agent.py @@ -8,7 +8,7 @@ from typing import Any from mcp import Tool -from mcp.types import TextContent +from mcp_types import TextContent from opentelemetry import trace from fast_agent.agents.agent_types import AgentConfig, AgentType diff --git a/src/fast_agent/agents/workflow/iterative_planner.py b/src/fast_agent/agents/workflow/iterative_planner.py index c155c7509..76ba00416 100644 --- a/src/fast_agent/agents/workflow/iterative_planner.py +++ b/src/fast_agent/agents/workflow/iterative_planner.py @@ -7,7 +7,7 @@ from a2a.types import AgentCard from mcp import Tool -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig, AgentType from fast_agent.agents.llm_agent import LlmAgent @@ -542,7 +542,7 @@ async def _get_next_step( ) request_params = self._merge_request_params( - request_params, RequestParams(systemPrompt=self.instruction) + request_params, RequestParams(system_prompt=self.instruction) ) # Get structured response from LLM diff --git a/src/fast_agent/agents/workflow/parallel_agent.py b/src/fast_agent/agents/workflow/parallel_agent.py index 41e5e37bb..3146fc3be 100644 --- a/src/fast_agent/agents/workflow/parallel_agent.py +++ b/src/fast_agent/agents/workflow/parallel_agent.py @@ -2,7 +2,7 @@ from typing import Any from mcp import Tool -from mcp.types import TextContent +from mcp_types import TextContent from opentelemetry import trace from fast_agent.agents.agent_types import AgentConfig, AgentType diff --git a/src/fast_agent/agents/workflow/router_agent.py b/src/fast_agent/agents/workflow/router_agent.py index 807c8b1a3..0b00a2b0d 100644 --- a/src/fast_agent/agents/workflow/router_agent.py +++ b/src/fast_agent/agents/workflow/router_agent.py @@ -113,7 +113,7 @@ def __init__( ) else: merged_params = RequestParams( - systemPrompt=ROUTING_SYSTEM_INSTRUCTION, + system_prompt=ROUTING_SYSTEM_INSTRUCTION, use_history=False, ) diff --git a/src/fast_agent/cli/checks/structured_tools_probe.py b/src/fast_agent/cli/checks/structured_tools_probe.py index 967ab3e05..e4e5982c6 100644 --- a/src/fast_agent/cli/checks/structured_tools_probe.py +++ b/src/fast_agent/cli/checks/structured_tools_probe.py @@ -228,7 +228,7 @@ async def _probe_direct_model(core: Core, model: str) -> ProbeResult: parsed, response = await agent.structured_schema( _build_direct_prompt(), DIRECT_PROBE_SCHEMA, - RequestParams(use_history=False, maxTokens=PROBE_MAX_TOKENS), + RequestParams(use_history=False, max_tokens=PROBE_MAX_TOKENS), ) if not isinstance(parsed, dict): raise ValueError(f"structured response was not a JSON object: {type(parsed).__name__}") @@ -294,7 +294,7 @@ async def _probe_pydantic_model(core: Core, model: str) -> ProbeResult: result, response = await agent.structured( _build_pydantic_prompt(), ProbeOrderSummary, - RequestParams(use_history=False, maxTokens=PROBE_MAX_TOKENS), + RequestParams(use_history=False, max_tokens=PROBE_MAX_TOKENS), ) if result is None: raise ValueError("structured response did not validate as ProbeOrderSummary") @@ -385,7 +385,7 @@ async def get_order_readiness(order_id: str) -> dict[str, Any]: use_history=False, structured_schema=PROBE_SCHEMA, structured_tool_policy=structured_tool_policy, - maxTokens=PROBE_MAX_TOKENS, + max_tokens=PROBE_MAX_TOKENS, max_iterations=4, ) diff --git a/src/fast_agent/cli/commands/auth.py b/src/fast_agent/cli/commands/auth.py index bfd16133d..0d941cd03 100644 --- a/src/fast_agent/cli/commands/auth.py +++ b/src/fast_agent/cli/commands/auth.py @@ -274,20 +274,27 @@ async def _run_login_session( try: # Use appropriate transport; connect and initialize a minimal session. if resolved_transport == "http": + import httpx2 + from mcp.client._probe import negotiate_auto from mcp.client.session import ClientSession - from mcp.client.streamable_http import streamablehttp_client + from mcp.client.streamable_http import streamable_http_client async with ( - streamablehttp_client(cfg.url or "", cfg.headers, auth=provider) as ( + httpx2.AsyncClient( + headers=cfg.headers, + auth=provider, + follow_redirects=True, + ) as http_client, + streamable_http_client(cfg.url or "", http_client=http_client) as ( read_stream, write_stream, - _get_session_id, ), ClientSession(read_stream, write_stream) as session, ): - await session.initialize() + await negotiate_auto(session) return True if resolved_transport == "sse": + from mcp.client._probe import negotiate_auto from mcp.client.session import ClientSession from mcp.client.sse import sse_client @@ -298,7 +305,7 @@ async def _run_login_session( ), ClientSession(read_stream, write_stream) as session, ): - await session.initialize() + await negotiate_auto(session) return True return False except Exception as e: diff --git a/src/fast_agent/cli/runtime/agent_setup.py b/src/fast_agent/cli/runtime/agent_setup.py index b762a87b1..699091d04 100644 --- a/src/fast_agent/cli/runtime/agent_setup.py +++ b/src/fast_agent/cli/runtime/agent_setup.py @@ -85,7 +85,7 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping - from mcp.types import ContentBlock + from mcp_types import ContentBlock from fast_agent.core.agent_app import AgentApp from fast_agent.core.harness import HarnessSession @@ -261,7 +261,7 @@ async def _build_cli_message_payload( if not blocks: return message - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.types import PromptMessageExtended @@ -420,7 +420,7 @@ async def _live_atif_tool_definitions(agent_obj: Any) -> list[dict[str, object]] "function": { "name": tool.name, "description": tool.description or "", - "parameters": tool.inputSchema, + "parameters": tool.input_schema, }, } for tool in listed.tools diff --git a/src/fast_agent/command_actions/models.py b/src/fast_agent/command_actions/models.py index 1eaedf2d1..b0f39a2a4 100644 --- a/src/fast_agent/command_actions/models.py +++ b/src/fast_agent/command_actions/models.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Protocol -from mcp.types import TextContent +from mcp_types import TextContent if TYPE_CHECKING: from collections.abc import Awaitable, Mapping diff --git a/src/fast_agent/commands/renderers/tools_markdown.py b/src/fast_agent/commands/renderers/tools_markdown.py index 1670cae1c..b1c744632 100644 --- a/src/fast_agent/commands/renderers/tools_markdown.py +++ b/src/fast_agent/commands/renderers/tools_markdown.py @@ -13,7 +13,7 @@ from fast_agent.utils.markdown import escape_markdown_text, markdown_code_span if TYPE_CHECKING: - from mcp.types import Tool + from mcp_types import Tool from fast_agent.commands.tool_summaries import ProviderToolSummary, ToolSummary @@ -21,7 +21,7 @@ def render_tool_schema_markdown(tool: "Tool") -> str: - schema = json.dumps(tool.inputSchema, ensure_ascii=False, indent=2) + schema = json.dumps(tool.input_schema, ensure_ascii=False, indent=2) name = escape_markdown_text(tool.name) return f"# Tool schema: {name}\n\n```json\n{schema}\n```" diff --git a/src/fast_agent/commands/tool_summaries.py b/src/fast_agent/commands/tool_summaries.py index 617b16797..b4191b6cc 100644 --- a/src/fast_agent/commands/tool_summaries.py +++ b/src/fast_agent/commands/tool_summaries.py @@ -32,7 +32,7 @@ PROVIDER_MANAGED_MCP_SUFFIX = "provider-managed MCP" if TYPE_CHECKING: - from mcp.types import Tool + from mcp_types import Tool from fast_agent.interfaces import FastAgentLLMProtocol from fast_agent.mcp.provider_management import ( @@ -351,7 +351,7 @@ def build_tool_summaries(agent: object, tools: list[Tool]) -> list[ToolSummary]: suffix = classification.suffix suffix = _append_app_tool_suffixes(suffix, meta) - args = _format_tool_args(tool.inputSchema) + args = _format_tool_args(tool.input_schema) template = optional_string(meta.get("ui/appTemplate")) or optional_string( meta.get("openai/skybridgeTemplate") ) diff --git a/src/fast_agent/context.py b/src/fast_agent/context.py index 7100a7ed4..90e927678 100644 --- a/src/fast_agent/context.py +++ b/src/fast_agent/context.py @@ -150,13 +150,11 @@ async def configure_otel(config: "Settings") -> None: ) from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor from opentelemetry.instrumentation.google_genai import GoogleGenAiSdkInstrumentor - from opentelemetry.instrumentation.mcp import McpInstrumentor from opentelemetry.instrumentation.openai import OpenAIInstrumentor OpenAIInstrumentor().instrument(tracer_provider=tracer_provider) GoogleGenAiSdkInstrumentor().instrument(tracer_provider=tracer_provider) AnthropicInstrumentor().instrument(tracer_provider=tracer_provider) - McpInstrumentor().instrument(tracer_provider=tracer_provider) async def configure_logger(config: "Settings") -> None: diff --git a/src/fast_agent/core/agent_app.py b/src/fast_agent/core/agent_app.py index 9e703b79e..b3e2aa9ea 100644 --- a/src/fast_agent/core/agent_app.py +++ b/src/fast_agent/core/agent_app.py @@ -34,7 +34,7 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from pathlib import Path - from mcp.types import GetPromptResult, PromptMessage + from mcp_types import GetPromptResult, PromptMessage from fast_agent.agents.agent_types import AgentType from fast_agent.cli.runtime.shell_cwd_policy import MissingShellCwdPolicy diff --git a/src/fast_agent/core/agent_card_loader.py b/src/fast_agent/core/agent_card_loader.py index b0ba905f5..dfcdca58c 100644 --- a/src/fast_agent/core/agent_card_loader.py +++ b/src/fast_agent/core/agent_card_loader.py @@ -431,7 +431,7 @@ def _apply_request_params_defaults( if request_params is None: return config.default_request_params = request_params - config.default_request_params.systemPrompt = config.instruction + config.default_request_params.system_prompt = config.instruction config.default_request_params.use_history = config.use_history diff --git a/src/fast_agent/core/harness.py b/src/fast_agent/core/harness.py index 6a9854429..f676772fc 100644 --- a/src/fast_agent/core/harness.py +++ b/src/fast_agent/core/harness.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar -from mcp.types import PromptMessage +from mcp_types import PromptMessage from pydantic import BaseModel from fast_agent.core.agent_instance_factory import ( diff --git a/src/fast_agent/core/harness_app.py b/src/fast_agent/core/harness_app.py index 532099b5d..3acde8ec6 100644 --- a/src/fast_agent/core/harness_app.py +++ b/src/fast_agent/core/harness_app.py @@ -19,7 +19,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping from contextlib import AbstractAsyncContextManager - from mcp.types import PromptMessage + from mcp_types import PromptMessage from fast_agent.config import Settings from fast_agent.core.agent_app import AgentApp diff --git a/src/fast_agent/history/compaction.py b/src/fast_agent/history/compaction.py index 4c06f5ca7..72399d732 100644 --- a/src/fast_agent/history/compaction.py +++ b/src/fast_agent/history/compaction.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Protocol, cast -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import FAST_AGENT_COMPACTION_CHANNEL from fast_agent.core.logging.logger import get_logger diff --git a/src/fast_agent/history/process_poll_folding.py b/src/fast_agent/history/process_poll_folding.py index 9f9af0249..a837559d1 100644 --- a/src/fast_agent/history/process_poll_folding.py +++ b/src/fast_agent/history/process_poll_folding.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.constants import ( FAST_AGENT_PROCESS_POLL_FOLD, diff --git a/src/fast_agent/history/tool_activities.py b/src/fast_agent/history/tool_activities.py index 30dd54afd..37b633c3c 100644 --- a/src/fast_agent/history/tool_activities.py +++ b/src/fast_agent/history/tool_activities.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal -from mcp.types import CallToolResult, ContentBlock, TextContent +from mcp_types import CallToolResult, ContentBlock, TextContent from fast_agent.constants import ANTHROPIC_ASSISTANT_RAW_CONTENT, ANTHROPIC_SERVER_TOOLS_CHANNEL from fast_agent.mcp.helpers.content_helpers import get_text @@ -48,7 +48,7 @@ def type_label(self) -> str: @property def is_error(self) -> bool: - return bool(self.result.isError) if self.result is not None else False + return bool(self.result.is_error) if self.result is not None else False def tool_activities_for_message( @@ -366,11 +366,11 @@ def _result_from_payload(payload: Mapping[str, Any]) -> CallToolResult: if isinstance(raw_content, Sequence) and not isinstance(raw_content, (str, bytes)): content.extend(_content_from_payload(item) for item in raw_content) - raw_is_error = payload.get("is_error") + raw_is_error = payload.get("isError") if not isinstance(raw_is_error, bool): - raw_is_error = payload.get("isError") + raw_is_error = payload.get("is_error") - return CallToolResult(content=content, isError=bool(raw_is_error)) + return CallToolResult(content=content, is_error=bool(raw_is_error)) def _content_from_payload(payload: object) -> ContentBlock: diff --git a/src/fast_agent/human_input/simple_form.py b/src/fast_agent/human_input/simple_form.py index 5fc7d5642..6035d771c 100644 --- a/src/fast_agent/human_input/simple_form.py +++ b/src/fast_agent/human_input/simple_form.py @@ -2,7 +2,7 @@ from typing import Any -from mcp.types import ElicitRequestedSchema +from mcp_types import ElicitRequestedSchema from fast_agent.human_input.form_fields import FormSchema from fast_agent.utils.async_utils import run_sync diff --git a/src/fast_agent/interfaces.py b/src/fast_agent/interfaces.py index 30f7c83a7..f8002f972 100644 --- a/src/fast_agent/interfaces.py +++ b/src/fast_agent/interfaces.py @@ -17,7 +17,7 @@ from a2a.types import AgentCard from mcp import Tool -from mcp.types import GetPromptResult, ListToolsResult, Prompt, PromptMessage, ReadResourceResult +from mcp_types import GetPromptResult, ListToolsResult, Prompt, PromptMessage, ReadResourceResult from pydantic import BaseModel from rich.text import Text diff --git a/src/fast_agent/llm/fastagent_llm.py b/src/fast_agent/llm/fastagent_llm.py index 853755272..30f6a5c37 100644 --- a/src/fast_agent/llm/fastagent_llm.py +++ b/src/fast_agent/llm/fastagent_llm.py @@ -22,7 +22,7 @@ from anthropic import BadRequestError as AnthropicBadRequestError from anthropic import RequestTooLargeError as AnthropicRequestTooLargeError from mcp import Tool -from mcp.types import GetPromptResult +from mcp_types import GetPromptResult from openai import APIError as OpenAIAPIError from openai import BadRequestError as OpenAIBadRequestError from pydantic_core import from_json @@ -114,9 +114,9 @@ class FastAgentLLM(ContextDependent, FastAgentLLMProtocol, Generic[MessageParamT # Common parameter names used across providers PARAM_MESSAGES = "messages" PARAM_MODEL = "model" - PARAM_MAX_TOKENS = "maxTokens" - PARAM_SYSTEM_PROMPT = "systemPrompt" - PARAM_STOP_SEQUENCES = "stopSequences" + PARAM_MAX_TOKENS = "max_tokens" + PARAM_SYSTEM_PROMPT = "system_prompt" + PARAM_STOP_SEQUENCES = "stop_sequences" PARAM_PARALLEL_TOOL_CALLS = "parallel_tool_calls" PARAM_METADATA = "metadata" PARAM_USE_HISTORY = "use_history" diff --git a/src/fast_agent/llm/internal/passthrough.py b/src/fast_agent/llm/internal/passthrough.py index 12d4b9a86..de1be6eba 100644 --- a/src/fast_agent/llm/internal/passthrough.py +++ b/src/fast_agent/llm/internal/passthrough.py @@ -2,7 +2,7 @@ from typing import Any from mcp import CallToolRequest, Tool -from mcp.types import CallToolRequestParams, PromptMessage +from mcp_types import CallToolRequestParams, PromptMessage from fast_agent.core.logging.logger import get_logger from fast_agent.llm.fastagent_llm import ( diff --git a/src/fast_agent/llm/provider/anthropic/llm_anthropic.py b/src/fast_agent/llm/provider/anthropic/llm_anthropic.py index 618ce603c..b628bd5ef 100644 --- a/src/fast_agent/llm/provider/anthropic/llm_anthropic.py +++ b/src/fast_agent/llm/provider/anthropic/llm_anthropic.py @@ -41,7 +41,7 @@ BetaToolUseBlockParam, ) from mcp import Tool -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolRequest, CallToolRequestParams, @@ -820,7 +820,7 @@ async def _upload_anthropic_file_bytes( @staticmethod def _anthropic_document_mime_type(resource: BlobResourceContents) -> str | None: - mime_type = normalize_mime_type(resource.mimeType) + mime_type = normalize_mime_type(resource.mime_type) if not mime_type and getattr(resource, "uri", None): mime_type = guess_mime_type(str(resource.uri)) if mime_type not in DOCUMENT_MIME_TYPES or mime_type == "application/pdf": @@ -1131,7 +1131,7 @@ async def _prepare_tools( BetaToolParam( name=tool.name, description=tool.description or "", - input_schema=tool.inputSchema, + input_schema=tool.input_schema, ) for tool in tools or [] ] @@ -1368,7 +1368,7 @@ async def _handle_structured_output_response( # Create the content for responses structured_content = TextContent(type="text", text=json.dumps(tool_args)) - tool_result = CallToolResult(isError=False, content=[structured_content]) + tool_result = CallToolResult(is_error=False, content=[structured_content]) messages.append( AnthropicConverter.create_tool_results_message([(tool_use_id, tool_result)]) ) @@ -2031,7 +2031,7 @@ def _build_anthropic_base_args( base_args: dict[str, Any] = { "model": model, "messages": messages, - "stop_sequences": params.stopSequences, + "stop_sequences": params.stop_sequences, } container_id = self._resolve_container_id_for_request(history, current_extended) if container_id: @@ -2040,8 +2040,8 @@ def _build_anthropic_base_args( if request_tools: base_args["tools"] = request_tools - if self.instruction or params.systemPrompt: - base_args["system"] = self.instruction or params.systemPrompt + if self.instruction or params.system_prompt: + base_args["system"] = self.instruction or params.system_prompt if structured_mode == "tool_use": if self._is_thinking_enabled(model) and self._requires_explicit_thinking_field(model): @@ -2064,7 +2064,7 @@ def _build_anthropic_base_args( thinking_args, thinking_enabled = self._resolve_thinking_arguments( model=model, - max_tokens=params.maxTokens, + max_tokens=params.max_tokens, structured_mode=structured_mode, ) base_args.update(thinking_args) diff --git a/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py b/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py index e8852771c..9fd8619a9 100644 --- a/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +++ b/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py @@ -24,7 +24,7 @@ BetaURLImageSourceParam, BetaURLPDFSourceParam, ) -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolResult, ContentBlock, @@ -428,7 +428,7 @@ def _convert_content_items( elif is_image_content(content_item): # Handle image content image_content = content_item - mime_type = image_content.mimeType or "" + mime_type = image_content.mime_type or "" # Check if image MIME type is supported if not AnthropicConverter._is_supported_image_type(mime_type): data_size = len(image_content.data) if image_content.data else 0 @@ -666,7 +666,7 @@ def _unsupported_blob_resource_text( mime_type: str, ) -> BetaContentBlockParam: blob_length = len(resource_content.blob) - uri_display = uri._url if uri else (uri_str or "") + uri_display = str(uri) if uri else (uri_str or "") return BetaTextBlockParam( type="text", text=( @@ -685,7 +685,7 @@ def _convert_resource_link( uri_str = str(resource.uri) if resource.uri else None parsed_uri = urlparse(uri_str) if uri_str else None is_url: bool = bool(parsed_uri and parsed_uri.scheme in ("http", "https")) - mime_type = resource.mimeType or (guess_mime_type(uri_str) if uri_str else None) or "" + mime_type = resource.mime_type or (guess_mime_type(uri_str) if uri_str else None) or "" from fast_agent.mcp.resource_utils import extract_title_from_uri @@ -732,8 +732,8 @@ def _determine_mime_type( Returns: The MIME type as a string """ - if resource.mimeType: - return resource.mimeType + if resource.mime_type: + return resource.mime_type if resource.uri: return guess_mime_type(str(resource.uri)) @@ -775,7 +775,7 @@ def _create_fallback_text(message: str, resource: ContentBlock) -> BetaTextBlock if isinstance(resource, EmbeddedResource): uri = resource.resource.uri if uri: - return BetaTextBlockParam(type="text", text=f"[{message}: {uri._url}]") + return BetaTextBlockParam(type="text", text=f"[{message}: {uri}]") if uri_str := get_resource_uri(resource): return BetaTextBlockParam(type="text", text=f"[{message}: {uri_str}]") @@ -830,7 +830,7 @@ def create_tool_results_message( type="tool_result", tool_use_id=sanitized_id, content=tool_result_blocks, - is_error=result.isError, + is_error=result.is_error, ) ) else: @@ -842,7 +842,7 @@ def create_tool_results_message( content=[ BetaTextBlockParam(type="text", text="[No content in tool result]") ], - is_error=result.isError, + is_error=result.is_error, ) ) diff --git a/src/fast_agent/llm/provider/bedrock/llm_bedrock.py b/src/fast_agent/llm/provider/bedrock/llm_bedrock.py index 6fdb793a1..636a296cd 100644 --- a/src/fast_agent/llm/provider/bedrock/llm_bedrock.py +++ b/src/fast_agent/llm/provider/bedrock/llm_bedrock.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast, get_args, get_origin from mcp import Tool -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, ContentBlock, @@ -674,7 +674,7 @@ def _convert_tools_nova_format( self.logger.debug(f"Converting MCP tool: {tool.name}") # Extract and validate the input schema - input_schema = tool.inputSchema or {} + input_schema = tool.input_schema or {} # Create Nova-compliant schema with ONLY the three allowed fields # Always include type and properties (even if empty) @@ -765,7 +765,7 @@ def _convert_tools_system_prompt_format( "function": { "name": tool_name, "description": tool.description or f"Tool: {tool.name}", - "parameters": tool.inputSchema or {"type": "object", "properties": {}}, + "parameters": tool.input_schema or {"type": "object", "properties": {}}, }, } @@ -804,7 +804,7 @@ def _convert_tools_anthropic_format( self.logger.debug(f"Converting MCP tool: {tool.name}") # Use raw MCP schema (like native Anthropic provider) - no cleaning - input_schema = tool.inputSchema or {"type": "object", "properties": {}} + input_schema = tool.input_schema or {"type": "object", "properties": {}} # Wrap in Bedrock toolSpec format but preserve raw Anthropic schema bedrock_tool = { @@ -1365,7 +1365,7 @@ def _bedrock_tool_list(tools: list[Tool] | None) -> "ListToolsResult | None": if not tools: return None - from mcp.types import ListToolsResult + from mcp_types import ListToolsResult return ListToolsResult(tools=tools) @@ -1842,10 +1842,10 @@ def _apply_bedrock_inference_config( caps: ModelCapabilities, ) -> int: inference_config: dict[str, Any] = {} - if params.maxTokens is not None: - inference_config["maxTokens"] = params.maxTokens - if params.stopSequences: - inference_config["stopSequences"] = params.stopSequences + if params.max_tokens is not None: + inference_config["maxTokens"] = params.max_tokens + if params.stop_sequences: + inference_config["stopSequences"] = params.stop_sequences reasoning_budget = self._resolve_reasoning_budget() reasoning_enabled = False @@ -2230,7 +2230,7 @@ async def _bedrock_completion( bedrock_messages = self._convert_messages_to_bedrock(messages) # Base system text - base_system_text = self.instruction or params.systemPrompt + base_system_text = self.instruction or params.system_prompt # Determine tool schema fallback order and caches caps = self.capabilities.get(model) or ModelCapabilities() @@ -2659,7 +2659,7 @@ def _structured_from_multipart( # If we cleaned the text, create a new multipart with the cleaned text if cleaned_text != text: - from mcp.types import TextContent + from mcp_types import TextContent cleaned_multipart = PromptMessageExtended( role=message.role, content=[TextContent(type="text", text=cleaned_text)] @@ -2675,7 +2675,7 @@ def _structured_from_multipart( return model_instance, parsed_multipart unwrapped_text = self._unwrap_structured_response_wrapper(cleaned_text, model) if unwrapped_text is not None: - from mcp.types import TextContent + from mcp_types import TextContent unwrapped_multipart = PromptMessageExtended( role=message.role, diff --git a/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py b/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py index 76b82dba5..6ad7697d9 100644 --- a/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +++ b/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py @@ -1,6 +1,6 @@ from typing import Any -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.mcp.helpers.content_helpers import ( canonicalize_tool_result_content_for_llm, @@ -46,7 +46,7 @@ def convert_to_bedrock(multipart_msg: PromptMessageExtended) -> BedrockMessagePa result_text = tool_result_text_for_llm(tool_result, source="bedrock.static") result_payload = { "tool_name": tool_id, - "status": "error" if tool_result.isError else "success", + "status": "error" if tool_result.is_error else "success", "result": result_text, } tool_result_parts.append(json.dumps(result_payload)) @@ -74,7 +74,7 @@ def convert_to_bedrock(multipart_msg: PromptMessageExtended) -> BedrockMessagePa "type": "tool_result", "tool_use_id": tool_id, "content": result_content_blocks, - "status": "error" if tool_result.isError else "success", + "status": "error" if tool_result.is_error else "success", } ) diff --git a/src/fast_agent/llm/provider/google/google_converter.py b/src/fast_agent/llm/provider/google/google_converter.py index 6fe2f80f0..c22c05ad3 100644 --- a/src/fast_agent/llm/provider/google/google_converter.py +++ b/src/fast_agent/llm/provider/google/google_converter.py @@ -4,7 +4,7 @@ # Import necessary types from google.genai from google.genai import types from mcp import Tool -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolRequestParams, CallToolResult, @@ -174,15 +174,15 @@ def _content_block_to_google_part(self, content: ContentBlock) -> types.Part | N @staticmethod def _image_content_to_google_part(content: ImageContent) -> types.Part: image_bytes = base64.b64decode(get_image_data(content) or "") - return types.Part.from_bytes(mime_type=content.mimeType, data=image_bytes) + return types.Part.from_bytes(mime_type=content.mime_type, data=image_bytes) def _embedded_resource_to_google_part(self, content: EmbeddedResource) -> types.Part: resource = content.resource - mime_type = getattr(resource, "mimeType", None) + mime_type = getattr(resource, "mime_type", None) if mime_type == "application/pdf" and isinstance(resource, BlobResourceContents): pdf_bytes = base64.b64decode(resource.blob) return types.Part.from_bytes( - mime_type=resource.mimeType or "application/pdf", + mime_type=resource.mime_type or "application/pdf", data=pdf_bytes, ) @@ -193,7 +193,7 @@ def _embedded_resource_to_google_part(self, content: EmbeddedResource) -> types. return types.Part.from_text(text=resource.text) uri_str = getattr(resource, "uri", "unknown_uri") - mime_str = getattr(resource, "mimeType", "unknown_mime") + mime_str = getattr(resource, "mime_type", "unknown_mime") return types.Part.from_text(text=f"[Resource: {uri_str}, MIME: {mime_str}]") @staticmethod @@ -203,7 +203,7 @@ def _media_resource_to_google_part(resource: Any, *, mime_type: str) -> types.Pa return types.Part.from_bytes(mime_type=mime_type, data=media_bytes) uri_str = getattr(resource, "uri", None) - mime_str = getattr(resource, "mimeType", "application/octet-stream") + mime_str = getattr(resource, "mime_type", "application/octet-stream") if uri_str: return types.Part.from_uri(file_uri=str(uri_str), mime_type=mime_str) @@ -211,7 +211,7 @@ def _media_resource_to_google_part(resource: Any, *, mime_type: str) -> types.Pa @staticmethod def _resource_link_to_google_part(content: ResourceLink) -> types.Part | None: - mime = content.mimeType + mime = content.mime_type uri_str = str(content.uri) if content.uri else None if uri_str and mime and mime.startswith(("video/", "audio/", "image/")): return types.Part.from_uri(file_uri=uri_str, mime_type=mime) @@ -227,7 +227,7 @@ def convert_to_google_tools(self, tools: list[Tool]) -> list[types.Tool]: """ google_tools: list[types.Tool] = [] for tool in tools: - cleaned_input_schema = self._clean_schema_for_google(tool.inputSchema) + cleaned_input_schema = self._clean_schema_for_google(tool.input_schema) function_declaration = types.FunctionDeclaration( name=tool.name, description=tool.description if tool.description else "", @@ -284,7 +284,7 @@ def convert_function_results_to_google( output_text = "\n".join(textual_outputs) function_response_payload: dict[str, Any] = ( {"error": output_text or "Tool execution failed."} - if tool_result.isError + if tool_result.is_error else {"result": output_text} ) fn_response_part = types.Part( @@ -340,7 +340,7 @@ def _image_tool_result_part( image_bytes = base64.b64decode(get_image_data(item) or "") return None, self._function_response_inline_part( data=image_bytes, - mime_type=item.mimeType, + mime_type=item.mime_type, ) except Exception as e: return f"[Error processing image from tool result: {e}]", None @@ -349,7 +349,7 @@ def _resource_tool_result_part( self, item: EmbeddedResource ) -> tuple[str | None, types.FunctionResponsePart | None]: resource = item.resource - mime_type = getattr(resource, "mimeType", None) + mime_type = getattr(resource, "mime_type", None) if mime_type == "application/pdf" and isinstance(resource, BlobResourceContents): return self._blob_tool_result_part( resource, @@ -366,7 +366,7 @@ def _resource_tool_result_part( return resource.text, None uri_str = getattr(resource, "uri", "unknown_uri") - mime_str = getattr(resource, "mimeType", "unknown_mime") + mime_str = getattr(resource, "mime_type", "unknown_mime") return f"[Unhandled Resource in Tool: {uri_str}, MIME: {mime_str}]", None def _blob_tool_result_part( @@ -388,7 +388,7 @@ def _blob_tool_result_part( def _resource_link_tool_result_part( self, item: ResourceLink ) -> tuple[str | None, types.FunctionResponsePart | None]: - mime = item.mimeType + mime = item.mime_type uri_str = str(item.uri) if item.uri else None if uri_str and mime and mime.startswith(("video/", "audio/", "image/")): return None, self._function_response_file_part(file_uri=uri_str, mime_type=mime) @@ -501,10 +501,10 @@ def _google_sampling_config_args( def _google_base_config_args(self, request_params: RequestParams) -> dict[str, Any]: config_args: dict[str, Any] = {} - if request_params.maxTokens is not None: - config_args["max_output_tokens"] = request_params.maxTokens + if request_params.max_tokens is not None: + config_args["max_output_tokens"] = request_params.max_tokens - stop_sequences = getattr(request_params, "stopSequences", None) + stop_sequences = getattr(request_params, "stop_sequences", None) if stop_sequences is not None: config_args["stop_sequences"] = stop_sequences @@ -520,8 +520,8 @@ def _google_base_config_args(self, request_params: RequestParams) -> dict[str, A if frequency_penalty is not None: config_args["frequency_penalty"] = frequency_penalty - if request_params.systemPrompt is not None: - config_args["system_instruction"] = request_params.systemPrompt + if request_params.system_prompt is not None: + config_args["system_instruction"] = request_params.system_prompt return config_args @staticmethod diff --git a/src/fast_agent/llm/provider/google/llm_google_native.py b/src/fast_agent/llm/provider/google/llm_google_native.py index 7bb089982..9801bb804 100644 --- a/src/fast_agent/llm/provider/google/llm_google_native.py +++ b/src/fast_agent/llm/provider/google/llm_google_native.py @@ -12,7 +12,7 @@ types, ) from mcp import Tool as McpTool -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, ContentBlock, @@ -351,12 +351,12 @@ def _initialize_default_params(self, kwargs: dict) -> RequestParams: return RequestParams( model=chosen_model, - systemPrompt=self.instruction, # System instruction will be mapped in _google_completion + system_prompt=self.instruction, # System instruction will be mapped in _google_completion parallel_tool_calls=True, # Assume parallel tool calls are supported by default with native API max_iterations=DEFAULT_MAX_ITERATIONS, use_history=True, # Pick a safe default per model (e.g. gemini-2.0-flash is limited to 8192). - maxTokens=max_tokens, + max_tokens=max_tokens, # Include other relevant default parameters ) diff --git a/src/fast_agent/llm/provider/openai/llm_generic.py b/src/fast_agent/llm/provider/openai/llm_generic.py index 0c46b536a..6be3eca3e 100644 --- a/src/fast_agent/llm/provider/openai/llm_generic.py +++ b/src/fast_agent/llm/provider/openai/llm_generic.py @@ -23,7 +23,7 @@ def _initialize_default_params(self, kwargs: dict) -> RequestParams: return RequestParams( model=chosen_model, - systemPrompt=self.instruction, + system_prompt=self.instruction, parallel_tool_calls=True, max_iterations=DEFAULT_MAX_ITERATIONS, use_history=True, diff --git a/src/fast_agent/llm/provider/openai/llm_google_oai.py b/src/fast_agent/llm/provider/openai/llm_google_oai.py index 55c4b6c9c..33be81b09 100644 --- a/src/fast_agent/llm/provider/openai/llm_google_oai.py +++ b/src/fast_agent/llm/provider/openai/llm_google_oai.py @@ -20,7 +20,7 @@ def _initialize_default_params(self, kwargs: dict) -> RequestParams: return RequestParams( model=chosen_model, - systemPrompt=self.instruction, + system_prompt=self.instruction, parallel_tool_calls=False, max_iterations=DEFAULT_MAX_ITERATIONS, use_history=True, diff --git a/src/fast_agent/llm/provider/openai/llm_openai.py b/src/fast_agent/llm/provider/openai/llm_openai.py index 373db26cb..407b6a1c1 100644 --- a/src/fast_agent/llm/provider/openai/llm_openai.py +++ b/src/fast_agent/llm/provider/openai/llm_openai.py @@ -8,7 +8,7 @@ import httpx from mcp import Tool -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, ContentBlock, @@ -1088,8 +1088,8 @@ def _openai_completion_request( messages = self._openai_completion_messages(message, params) available_tools = self._openai_completion_tools(tools, model_name) arguments = self._prepare_api_request(messages, available_tools, params) - if not self._reasoning and params.stopSequences: - arguments["stop"] = params.stopSequences + if not self._reasoning and params.stop_sequences: + arguments["stop"] = params.stop_sequences return _OpenAICompletionRequest( params=params, model_name=model_name, @@ -1103,7 +1103,7 @@ def _openai_completion_messages( request_params: RequestParams, ) -> list[ChatCompletionMessageParam]: messages: list[ChatCompletionMessageParam] = [] - system_prompt = self.instruction or request_params.systemPrompt + system_prompt = self.instruction or request_params.system_prompt if system_prompt: messages.append(ChatCompletionSystemMessageParam(role="system", content=system_prompt)) if message: @@ -1123,7 +1123,7 @@ def _openai_completion_tools( "function": { "name": tool.name, "description": tool.description if tool.description else "", - "parameters": self.adjust_schema(tool.inputSchema, model_name=model_name), + "parameters": self.adjust_schema(tool.input_schema, model_name=model_name), }, } for tool in tools or [] @@ -1435,13 +1435,13 @@ def _prepare_api_request( if self._reasoning: effort = self._resolve_reasoning_effort() - if request_params.maxTokens is not None: - base_args["max_completion_tokens"] = request_params.maxTokens + if request_params.max_tokens is not None: + base_args["max_completion_tokens"] = request_params.max_tokens if effort: base_args["reasoning_effort"] = effort else: - if request_params.maxTokens is not None: - base_args["max_tokens"] = request_params.maxTokens + if request_params.max_tokens is not None: + base_args["max_tokens"] = request_params.max_tokens if tools: base_args["parallel_tool_calls"] = request_params.parallel_tool_calls @@ -1542,12 +1542,12 @@ def _convert_extended_messages_to_provider( return converted - def adjust_schema(self, inputSchema: dict, model_name: str | None = None) -> dict: + def adjust_schema(self, input_schema: dict, model_name: str | None = None) -> dict: effective_model = model_name or self.default_request_params.model result = ( - sanitize_tool_input_schema(inputSchema) + sanitize_tool_input_schema(input_schema) if should_strip_tool_schema_defaults(effective_model) - else inputSchema + else input_schema ) if self.provider not in [Provider.OPENAI, Provider.AZURE]: diff --git a/src/fast_agent/llm/provider/openai/llm_tensorzero_openai.py b/src/fast_agent/llm/provider/openai/llm_tensorzero_openai.py index ab82bae42..4564c677a 100644 --- a/src/fast_agent/llm/provider/openai/llm_tensorzero_openai.py +++ b/src/fast_agent/llm/provider/openai/llm_tensorzero_openai.py @@ -40,7 +40,7 @@ def _initialize_default_params(self, kwargs: dict) -> RequestParams: return RequestParams( model=model, - systemPrompt=self.instruction, + system_prompt=self.instruction, parallel_tool_calls=True, max_iterations=DEFAULT_MAX_ITERATIONS, use_history=True, diff --git a/src/fast_agent/llm/provider/openai/multipart_converter_openai.py b/src/fast_agent/llm/provider/openai/multipart_converter_openai.py index c8d403adb..7b4f80742 100644 --- a/src/fast_agent/llm/provider/openai/multipart_converter_openai.py +++ b/src/fast_agent/llm/provider/openai/multipart_converter_openai.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping from typing import Any -from mcp.types import ( +from mcp_types import ( AudioContent, BlobResourceContents, CallToolRequest, @@ -13,7 +13,7 @@ TextContent, TextResourceContents, ) -from mcp.types import ( +from mcp_types import ( ContentBlock as MCPContentBlock, ) from openai.types.chat import ( @@ -253,13 +253,13 @@ def _convert_content_item(item: Any) -> ContentBlock | None: @staticmethod def _convert_audio_content(item: AudioContent) -> ContentBlock: - mime_type = item.mimeType or "audio" + mime_type = item.mime_type or "audio" return {"type": "text", "text": f"[Unsupported audio content: {mime_type}]"} @staticmethod def _convert_resource_link_content(item: ResourceLink) -> ContentBlock | None: uri = item.uri - mime_type = item.mimeType + mime_type = item.mime_type if uri and mime_type and OpenAIConverter._is_supported_image_type(mime_type): return {"type": "image_url", "image_url": {"url": str(uri)}} if uri and mime_type and is_document_mime_type(mime_type): @@ -315,7 +315,7 @@ def _convert_image_content(content: ImageContent) -> ContentBlock: image_data = get_image_data(content) # OpenAI requires image URLs or data URIs for images - image_url = {"url": f"data:{content.mimeType};base64,{image_data}"} + image_url = {"url": f"data:{content.mime_type};base64,{image_data}"} return {"type": "image_url", "image_url": image_url} @@ -330,8 +330,8 @@ def _determine_mime_type(resource_content: McpResourceContents) -> str: Returns: The determined MIME type as a string """ - if resource_content.mimeType: - return resource_content.mimeType + if resource_content.mime_type: + return resource_content.mime_type return guess_mime_type(str(resource_content.uri)) diff --git a/src/fast_agent/llm/provider/openai/responses.py b/src/fast_agent/llm/provider/openai/responses.py index d8fe9277c..83881a407 100644 --- a/src/fast_agent/llm/provider/openai/responses.py +++ b/src/fast_agent/llm/provider/openai/responses.py @@ -6,7 +6,7 @@ from typing import Any, ClassVar, Literal from mcp import Tool -from mcp.types import ContentBlock, TextContent +from mcp_types import ContentBlock, TextContent from openai import APIError, AsyncOpenAI, AuthenticationError, DefaultAioHttpClient from fast_agent.constants import ( @@ -760,7 +760,7 @@ def _build_declared_tools_payload( "type": "function", "name": tool.name, "description": tool.description or "", - "parameters": self._adjust_schema(tool.inputSchema, model), + "parameters": self._adjust_schema(tool.input_schema, model), "strict": False, } ) @@ -834,10 +834,10 @@ def _apply_response_max_tokens( base_args: dict[str, Any], request_params: RequestParams, ) -> None: - if request_params.maxTokens is None: + if request_params.max_tokens is None: return - max_tokens = request_params.maxTokens + max_tokens = request_params.max_tokens if max_tokens < MIN_RESPONSES_MAX_TOKENS: self.logger.debug( "Clamping max_output_tokens to Responses minimum", @@ -894,7 +894,7 @@ def _build_response_args( "parallel_tool_calls": request_params.parallel_tool_calls, } - system_prompt = self.instruction or request_params.systemPrompt + system_prompt = self.instruction or request_params.system_prompt if system_prompt: base_args["instructions"] = system_prompt diff --git a/src/fast_agent/llm/provider/openai/responses_content.py b/src/fast_agent/llm/provider/openai/responses_content.py index 945e7bcbd..29f55b3b0 100644 --- a/src/fast_agent/llm/provider/openai/responses_content.py +++ b/src/fast_agent/llm/provider/openai/responses_content.py @@ -3,7 +3,7 @@ import json from typing import TYPE_CHECKING, Any -from mcp.types import CallToolRequest, ContentBlock, EmbeddedResource +from mcp_types import CallToolRequest, ContentBlock, EmbeddedResource from fast_agent.constants import ( ANTHROPIC_SERVER_TOOLS_CHANNEL, @@ -298,9 +298,9 @@ def _build_reasoning_summary_payload( @staticmethod def _content_mime_type(content: ContentBlock) -> str | None: - mime_type = getattr(content, "mimeType", None) + mime_type = getattr(content, "mime_type", None) if isinstance(content, EmbeddedResource): - mime_type = getattr(content.resource, "mimeType", None) + mime_type = getattr(content.resource, "mime_type", None) return mime_type @staticmethod diff --git a/src/fast_agent/llm/provider/openai/responses_output.py b/src/fast_agent/llm/provider/openai/responses_output.py index a4e68c286..7c06c0852 100644 --- a/src/fast_agent/llm/provider/openai/responses_output.py +++ b/src/fast_agent/llm/provider/openai/responses_output.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal -from mcp.types import CallToolRequest, CallToolRequestParams, ContentBlock, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, ContentBlock, TextContent from openai.types.responses import ResponseReasoningItem, ResponseUsage from pydantic_core import from_json diff --git a/src/fast_agent/llm/provider/openai/xai_responses.py b/src/fast_agent/llm/provider/openai/xai_responses.py index 4d12718ae..c4d0939f7 100644 --- a/src/fast_agent/llm/provider/openai/xai_responses.py +++ b/src/fast_agent/llm/provider/openai/xai_responses.py @@ -4,7 +4,7 @@ import os from typing import TYPE_CHECKING, Any -from mcp.types import ContentBlock, TextContent +from mcp_types import ContentBlock, TextContent from fast_agent.core.exceptions import ProviderKeyError from fast_agent.llm.provider.openai.responses import ResponsesLLM diff --git a/src/fast_agent/llm/request_param_resolution.py b/src/fast_agent/llm/request_param_resolution.py index e3363d62f..dbb00c2b3 100644 --- a/src/fast_agent/llm/request_param_resolution.py +++ b/src/fast_agent/llm/request_param_resolution.py @@ -121,8 +121,8 @@ def initialize_base_default_params( return RequestParams( model=model, - maxTokens=max_tokens, - systemPrompt=instruction, + max_tokens=max_tokens, + system_prompt=instruction, parallel_tool_calls=True, max_iterations=DEFAULT_MAX_ITERATIONS, use_history=True, diff --git a/src/fast_agent/llm/request_params.py b/src/fast_agent/llm/request_params.py index 24ba6cf51..fd81405e3 100644 --- a/src/fast_agent/llm/request_params.py +++ b/src/fast_agent/llm/request_params.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeGuard from mcp import SamplingMessage -from mcp.types import CreateMessageRequestParams +from mcp_types import CreateMessageRequestParams from pydantic import AliasChoices, BaseModel, Field, field_validator from fast_agent.constants import DEFAULT_MAX_ITERATIONS, DEFAULT_STREAMING_TIMEOUT @@ -71,7 +71,7 @@ class RequestParams(CreateMessageRequestParams): to avoid confusion with the 'message' parameter on 'generate' method. """ - maxTokens: int | None = None + max_tokens: int | None = None """The maximum number of tokens to sample, as requested by the server.""" model: str | None = None @@ -203,7 +203,7 @@ class RequestParams(CreateMessageRequestParams): """Responses-family service tier override (fast/priority or flex).""" @field_validator( - "maxTokens", + "max_tokens", "max_iterations", "streaming_timeout", "temperature", diff --git a/src/fast_agent/llm/response_telemetry.py b/src/fast_agent/llm/response_telemetry.py index 7d16c8245..2c22e0ff5 100644 --- a/src/fast_agent/llm/response_telemetry.py +++ b/src/fast_agent/llm/response_telemetry.py @@ -4,7 +4,7 @@ import time from typing import TYPE_CHECKING, Any, Protocol -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import FAST_AGENT_TIMING, FAST_AGENT_USAGE diff --git a/src/fast_agent/llm/retry_telemetry.py b/src/fast_agent/llm/retry_telemetry.py index db4a6fff7..464bc8aeb 100644 --- a/src/fast_agent/llm/retry_telemetry.py +++ b/src/fast_agent/llm/retry_telemetry.py @@ -4,7 +4,7 @@ from dataclasses import asdict, dataclass from typing import Literal -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import FAST_AGENT_RETRY from fast_agent.llm.provider.streaming_timeouts import StreamIdleTimeoutError diff --git a/src/fast_agent/llm/sampling_converter.py b/src/fast_agent/llm/sampling_converter.py index 7de11b4b3..cacac7c2c 100644 --- a/src/fast_agent/llm/sampling_converter.py +++ b/src/fast_agent/llm/sampling_converter.py @@ -3,7 +3,7 @@ This replaces the more complex provider-specific converters with direct conversions. """ -from mcp.types import ( +from mcp_types import ( AudioContent, CallToolRequest, CallToolRequestParams, @@ -81,10 +81,10 @@ def sampling_message_to_prompt_message( # Extract text from content list if present result_content = item.content if item.content else [] # isError can be None, so we need to handle that - is_error = getattr(item, "isError", None) - tool_results[item.toolUseId] = CallToolResult( + is_error = getattr(item, "is_error", None) + tool_results[item.tool_use_id] = CallToolResult( content=result_content, - isError=is_error if is_error is not None else False, + is_error=is_error if is_error is not None else False, ) return PromptMessageExtended( @@ -106,11 +106,11 @@ def extract_request_params(params: CreateMessageRequestParams) -> RequestParams: RequestParams suitable for use with LLM.generate_prompt """ return RequestParams( - maxTokens=params.maxTokens, - systemPrompt=params.systemPrompt, + max_tokens=params.max_tokens, + system_prompt=params.system_prompt, temperature=params.temperature, - stopSequences=params.stopSequences, - modelPreferences=params.modelPreferences, + stop_sequences=params.stop_sequences, + model_preferences=params.model_preferences, # Add any other parameters needed ) @@ -130,7 +130,7 @@ def error_result(error_message: str, model: str | None = None) -> CreateMessageR role="assistant", content=TextContent(type="text", text=error_message), model=model or "unknown", - stopReason=LlmStopReason.ERROR.value, + stop_reason=LlmStopReason.ERROR.value, ) @staticmethod diff --git a/src/fast_agent/mcp/auth/huggingface.py b/src/fast_agent/mcp/auth/huggingface.py index 4e0ae8890..1c23c8238 100644 --- a/src/fast_agent/mcp/auth/huggingface.py +++ b/src/fast_agent/mcp/auth/huggingface.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from typing import Any -import httpx +import httpx2 as httpx from fastmcp.server.auth import AccessToken from fastmcp.server.auth.providers.huggingface import ( DEFAULT_HUGGINGFACE_SCOPES, diff --git a/src/fast_agent/mcp/elicitation_handlers.py b/src/fast_agent/mcp/elicitation_handlers.py index af4e92040..d85c4a326 100644 --- a/src/fast_agent/mcp/elicitation_handlers.py +++ b/src/fast_agent/mcp/elicitation_handlers.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from mcp.types import ( +from mcp_types import ( ElicitRequestFormParams, ElicitRequestParams, ElicitRequestURLParams, @@ -23,8 +23,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from mcp import ClientSession - from mcp.shared.context import RequestContext + from mcp.client.session import ClientRequestContext from fast_agent.human_input.types import HumanInputResponse from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession @@ -150,7 +149,7 @@ def _parse_elicitation_content( async def auto_cancel_elicitation_handler( - context: RequestContext["ClientSession", Any], + context: ClientRequestContext, params: ElicitRequestParams, ) -> ElicitResult | ErrorData: """Handler that automatically cancels all elicitation requests. @@ -164,7 +163,7 @@ async def auto_cancel_elicitation_handler( def _mcp_agent_session( - context: RequestContext["ClientSession", Any], + context: ClientRequestContext, ) -> MCPAgentClientSession | None: from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession @@ -175,7 +174,7 @@ def _mcp_agent_session( def _elicitation_context_info( - context: RequestContext["ClientSession", Any], + context: ClientRequestContext, ) -> _ElicitationContextInfo: server_config = get_server_config(context) session = _mcp_agent_session(context) @@ -197,7 +196,7 @@ def _handle_url_elicitation( context_info: _ElicitationContextInfo, ) -> ElicitResult: url = str(params.url) - elicitation_id = str(params.elicitationId) if params.elicitationId is not None else None + elicitation_id = str(params.elicitation_id) if params.elicitation_id is not None else None logger.info( f"URL elicitation from {context_info.server_name}: {url} (elicitationId={elicitation_id})" ) @@ -231,7 +230,7 @@ def _form_human_input_request( ): from fast_agent.human_input.types import HumanInputRequest - requested_schema = params.requestedSchema + requested_schema = params.requested_schema return HumanInputRequest( prompt=params.message, description=f"Schema: {requested_schema}" if requested_schema else None, @@ -286,14 +285,14 @@ async def _handle_form_elicitation( if special_result := _special_elicitation_action(response_data, context_info.server_name): return special_result - content = _parse_elicitation_content(response, params.requestedSchema) + content = _parse_elicitation_content(response, params.requested_schema) if content is None: return ElicitResult(action="decline") return ElicitResult(action="accept", content=content) async def forms_elicitation_handler( - context: RequestContext["ClientSession", Any], params: ElicitRequestParams + context: ClientRequestContext, params: ElicitRequestParams ) -> ElicitResult: """ Combined elicitation handler supporting both form and URL modes. diff --git a/src/fast_agent/mcp/helpers/content_helpers.py b/src/fast_agent/mcp/helpers/content_helpers.py index c6a124a21..11c447d5e 100644 --- a/src/fast_agent/mcp/helpers/content_helpers.py +++ b/src/fast_agent/mcp/helpers/content_helpers.py @@ -6,7 +6,7 @@ import json from typing import TYPE_CHECKING, Protocol, TypeGuard, cast -from mcp.types import ( +from mcp_types import ( BlobResourceContents, ContentBlock, EmbeddedResource, @@ -46,7 +46,7 @@ def get_text(content: object) -> str | None: if isinstance(content, ResourceLink): name = content.name or "unknown" uri_str = str(content.uri) - mime_type = content.mimeType or "unknown" + mime_type = content.mime_type or "unknown" description = content.description or "" lines = [ @@ -153,7 +153,7 @@ def canonicalize_tool_result_content_for_llm( raw_content = getattr(result, "content", None) content = cast("list[ContentBlock]", list(raw_content)) if isinstance(raw_content, list) else [] - structured_content = getattr(result, "structuredContent", None) + structured_content = getattr(result, "structured_content", None) if structured_content is None: return content @@ -257,13 +257,11 @@ def resource_link( Returns: A ResourceLink object """ - from pydantic import AnyUrl - return ResourceLink( type="resource_link", - uri=AnyUrl(url), + uri=url, name=name or _extract_name_from_url(url), - mimeType=mime_type or _infer_mime_type(url), + mime_type=mime_type or _infer_mime_type(url), description=description, ) diff --git a/src/fast_agent/mcp/interfaces.py b/src/fast_agent/mcp/interfaces.py index cc7a19aa2..3f52e5331 100644 --- a/src/fast_agent/mcp/interfaces.py +++ b/src/fast_agent/mcp/interfaces.py @@ -4,9 +4,9 @@ """ from contextlib import AbstractAsyncContextManager -from datetime import timedelta from typing import ( TYPE_CHECKING, + Any, Protocol, runtime_checkable, ) @@ -24,7 +24,14 @@ ) if TYPE_CHECKING: - from mcp.types import ServerCapabilities + from mcp.shared.dispatcher import ProgressFnT + from mcp_types import ( + CallToolResult, + GetPromptResult, + ReadResourceResult, + RequestParamsMeta, + ServerCapabilities, + ) from fast_agent.config import MCPServerSettings from fast_agent.mcp.transport_tracking import TransportChannelMetrics @@ -32,6 +39,7 @@ __all__ = [ "AgentProtocol", "ClientSessionFactory", + "CompletingClientSession", "FastAgentLLMProtocol", "LLMFactoryProtocol", "LlmAgentProtocol", @@ -43,6 +51,36 @@ ] +@runtime_checkable +class CompletingClientSession(Protocol): + """Session operations that resolve modern input-required rounds.""" + + async def call_tool_complete( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: "ProgressFnT | None" = None, + *, + meta: dict[str, Any] | None = None, + ) -> "CallToolResult": ... + + async def read_resource_complete( + self, + uri: str, + *, + meta: "RequestParamsMeta | None" = None, + ) -> "ReadResourceResult": ... + + async def get_prompt_complete( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + meta: "RequestParamsMeta | None" = None, + ) -> "GetPromptResult": ... + + @runtime_checkable class ClientSessionFactory(Protocol): """Protocol for creating client sessions across persistent and temporary connections.""" @@ -51,7 +89,7 @@ def __call__( self, read_stream: MemoryObjectReceiveStream, write_stream: MemoryObjectSendStream, - read_timeout: timedelta | None, + read_timeout: float | None, *, server_config: "MCPServerSettings | None" = None, transport_metrics: "TransportChannelMetrics | None" = None, diff --git a/src/fast_agent/mcp/mcp_agent_client_session.py b/src/fast_agent/mcp/mcp_agent_client_session.py index 135add433..22b30a3e8 100644 --- a/src/fast_agent/mcp/mcp_agent_client_session.py +++ b/src/fast_agent/mcp/mcp_agent_client_session.py @@ -9,38 +9,42 @@ import json import sys from contextlib import suppress -from typing import TYPE_CHECKING, Any, cast - -from mcp import ClientSession, ServerNotification -from mcp.types import ( +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from mcp import ClientSession +from mcp.client._input_required import run_input_required_driver +from mcp.client.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ServerEvent, + ToolsListChanged, +) +from mcp_types import ( URL_ELICITATION_REQUIRED, - CallToolRequest, - CallToolRequestParams, CallToolResult, ClientRequest, EmptyResult, - GetPromptRequest, - GetPromptRequestParams, + ErrorData, GetPromptResult, Implementation, + InputRequest, + InputRequiredResult, + InputResponse, + InputResponses, ListResourcesResult, ListRootsResult, PaginatedRequestParams, - PingRequest, ProgressNotification, - ReadResourceRequest, - ReadResourceRequestParams, ReadResourceResult, Request, - RequestParams, + RequestParamsMeta, Root, SamplingCapability, SamplingToolsCapability, ToolListChangedNotification, ) -from pydantic import AnyUrl, FileUrl -from pydantic.networks import UrlConstraints -from typing_extensions import Annotated, Literal +from pydantic import AnyUrl, BaseModel, FileUrl, TypeAdapter +from typing_extensions import Literal from fast_agent.context_dependent import ContextDependent from fast_agent.core.logging.logger import get_logger @@ -48,6 +52,7 @@ from fast_agent.mcp.sampling import resolve_auto_sampling_enabled, sample from fast_agent.mcp.tool_result_metadata import ( set_url_elicitation_required_payload, + update_tool_result_display_metadata, url_elicitation_required_payload, ) from fast_agent.mcp.url_elicitation_required import ( @@ -59,23 +64,21 @@ from fast_agent.utils.text import strip_casefold if TYPE_CHECKING: - from datetime import timedelta - - from mcp.client.session import ListRootsFnT, SamplingFnT - from mcp.shared.context import RequestContext - from mcp.shared.message import MessageMetadata - from mcp.shared.session import ProgressFnT, ReceiveResultT + from mcp.client.session import ClientRequestContext, ListRootsFnT, SamplingFnT + from mcp.shared.dispatcher import ProgressFnT + from mcp.shared.message import ClientMessageMetadata from fast_agent.config import MCPServerSettings from fast_agent.mcp.transport_tracking import TransportChannelMetrics logger = get_logger(__name__) +ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) class DirectoryReadRequestParams(PaginatedRequestParams): """Parameters for the SEP-2640 ``resources/directory/read`` method.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str class DirectoryReadRequest( @@ -100,7 +103,7 @@ def _progress_trace(message: str) -> None: _URL_ELICITATION_RESULT_PREFIX = "fast-agent-url-elicitation-required:" -async def list_roots(context: RequestContext[ClientSession, None]) -> ListRootsResult: +async def list_roots(context: ClientRequestContext) -> ListRootsResult: """List roots callback that will be called by the MCP library.""" if (server_config := get_server_config(context.session)) and server_config.roots: @@ -155,6 +158,7 @@ def __init__(self, read_stream, write_stream, read_timeout=None, **kwargs) -> No sampling_capabilities=sampling_caps, client_info=fast_agent, elicitation_callback=elicitation_handler, + message_handler=self._handle_message, ) def _pop_fast_agent_kwargs(self, kwargs: dict[str, Any]) -> Any: @@ -259,6 +263,7 @@ def _discard_managed_client_kwargs(kwargs: dict[str, Any]) -> None: "sampling_capabilities", "client_info", "elicitation_callback", + "message_handler", ): kwargs.pop(key, None) @@ -273,16 +278,16 @@ def _should_enable_auto_sampling(self) -> bool: async def send_request( self, - request: ClientRequest, - result_type: type[ReceiveResultT], - request_read_timeout_seconds: timedelta | None = None, - metadata: MessageMetadata | None = None, + request: ClientRequest | Request[Any, Any], + result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT], + request_read_timeout_seconds: float | None = None, + metadata: ClientMessageMetadata | None = None, progress_callback: ProgressFnT | None = None, ) -> ReceiveResultT: logger.debug("send_request: request=", data=request.model_dump()) - request_id = getattr(self, "_request_id", None) + request_id = None is_ping_request = self._is_ping_request(request) - request_method = getattr(getattr(request, "root", None), "method", "unknown") + request_method = request.method self._trace_request_progress( "outbound-request", @@ -431,9 +436,8 @@ def _raise_connection_offline(self, exc: Exception) -> None: raise ConnectionError(f"MCP server {self.session_server_name} offline") from exc @staticmethod - def _is_ping_request(request: ClientRequest) -> bool: - root = getattr(request, "root", None) - method = getattr(root, "method", None) + def _is_ping_request(request: Any) -> bool: + method = request.method if not isinstance(method, str): return False method_lower = strip_casefold(method) @@ -441,45 +445,31 @@ def _is_ping_request(request: ClientRequest) -> bool: def _is_session_terminated_error(self, exc: Exception) -> bool: """Check if exception is a session terminated error (code 32600 from 404).""" - from mcp.shared.exceptions import McpError + from mcp.shared.exceptions import MCPError from fast_agent.core.exceptions import ServerSessionTerminatedError - if isinstance(exc, McpError): - error_data = getattr(exc, "error", None) - if error_data: - code = getattr(error_data, "code", None) - if code == ServerSessionTerminatedError.SESSION_TERMINATED_CODE: - return True - return False + return ( + isinstance(exc, MCPError) + and exc.code == ServerSessionTerminatedError.SESSION_TERMINATED_CODE + ) def _is_url_elicitation_required_error(self, exc: Exception) -> bool: """Check if exception is URL elicitation required error (-32042).""" - from mcp.shared.exceptions import McpError - - if not isinstance(exc, McpError): - return False - - error_data = getattr(exc, "error", None) - if error_data is None: - return False + from mcp.shared.exceptions import MCPError - return getattr(error_data, "code", None) == URL_ELICITATION_REQUIRED + return isinstance(exc, MCPError) and exc.code == URL_ELICITATION_REQUIRED def _attach_url_elicitation_required_payload(self, exc: Exception, request_method: str) -> None: """Attach parsed URL elicitation data to exception for deferred display.""" - from mcp.shared.exceptions import McpError - - if not isinstance(exc, McpError): - return + from mcp.shared.exceptions import MCPError - error_data = getattr(exc, "error", None) - if error_data is None: + if not isinstance(exc, MCPError): return server_name = self.session_server_name or "unknown" payload = build_url_elicitation_required_display_payload( - error_data.data, + exc.data, server_name=server_name, request_method=request_method, ) @@ -540,7 +530,7 @@ def _attach_url_elicitation_payload_from_result( *, request_method: str, ) -> None: - if not isinstance(result, CallToolResult) or not result.isError or not result.content: + if not isinstance(result, CallToolResult) or not result.is_error or not result.content: return first_block = result.content[0] @@ -578,49 +568,18 @@ def _attach_transport_channel(self, request_id, result) -> None: if not channel: return with suppress(Exception): - result_meta = cast("Any", result) - result_meta.transport_channel = channel + update_tool_result_display_metadata(result, {"transport_channel": channel}) - async def _received_notification(self, notification: ServerNotification) -> None: - """ - Can be overridden by subclasses to handle a notification without needing - to listen on the message stream. - """ - logger.debug( - "_received_notification: notification=", - data=notification.model_dump(), - ) - - # Call parent notification handler first - await super()._received_notification(notification) - - # Then process our specific notification types - match notification.root: - case ToolListChangedNotification(): - # Simple notification handling - just call the callback if it exists - if self._tool_list_changed_callback and self.session_server_name: - logger.info( - f"Tool list changed for server '{self.session_server_name}', triggering callback" - ) - asyncio.create_task( - self._handle_tool_list_change_callback(self.session_server_name) - ) - else: - logger.debug( - f"Tool list changed for server '{self.session_server_name}' but no callback registered" - ) - - # Forward non-progress server notifications to the aggregator callback. - # Progress updates already flow through the request progress callback path. - _cb = ( - getattr(self._aggregator, "server_notification_callback", None) - if self._aggregator - else None - ) - if _cb and not isinstance(notification.root, ProgressNotification): - asyncio.create_task(self._handle_server_notification(notification)) + async def _handle_message(self, message: object) -> None: + if isinstance(message, ToolListChangedNotification): + if self._tool_list_changed_callback and self.session_server_name: + asyncio.create_task( + self._handle_tool_list_change_callback(self.session_server_name) + ) + if self._aggregator and not isinstance(message, ProgressNotification | Exception): + asyncio.create_task(self._handle_server_notification(message)) - async def _handle_server_notification(self, notification: ServerNotification) -> None: + async def _handle_server_notification(self, notification: object) -> None: """Forward server notifications to the registered callback.""" _cb = ( getattr(self._aggregator, "server_notification_callback", None) @@ -639,6 +598,30 @@ async def _handle_server_notification(self, notification: ServerNotification) -> f"Error in server notification callback for '{self.session_server_name}': {e}" ) + async def handle_subscription_event(self, event: ServerEvent) -> None: + if self._aggregator is None or self.session_server_name is None: + return + if isinstance(event, ToolsListChanged): + await self._aggregator._handle_tool_list_changed(self.session_server_name) + elif isinstance(event, PromptsListChanged): + await self._aggregator._fetch_and_cache_prompts(self.session_server_name) + elif isinstance(event, ResourcesListChanged): + await self._aggregator._refresh_server_resources(self.session_server_name) + + async def _dispatch_input( + self, + key: str, + request: InputRequest, + ) -> InputResponse | ErrorData: + from mcp.client.session import ClientRequestContext + + context = ClientRequestContext( + session=self, + request_id=key, + meta=request.params.meta if request.params else None, + ) + return await self.dispatch_input_request(context, request) + async def _handle_tool_list_change_callback(self, server_name: str) -> None: """ Helper method to handle tool list change callback in a separate task @@ -649,11 +632,11 @@ async def _handle_tool_list_change_callback(self, server_name: str) -> None: except Exception as e: logger.error(f"Error in tool list changed callback: {e}") - async def call_tool( + async def call_tool_complete( self, name: str, arguments: dict[str, Any] | None = None, - read_timeout_seconds: timedelta | None = None, + read_timeout_seconds: float | None = None, progress_callback: ProgressFnT | None = None, *, meta: dict[str, Any] | None = None, @@ -663,56 +646,75 @@ async def call_tool( Always uses our overridden send_request to ensure session terminated errors are properly detected and converted to ServerSessionTerminatedError. """ - request_meta = RequestParams.Meta(**meta) if meta is not None else None - return await self.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name=name, - arguments=arguments, - _meta=request_meta, - ) - ) - ), - CallToolResult, - request_read_timeout_seconds=read_timeout_seconds, - progress_callback=progress_callback, + first = await super().call_tool( + name, + arguments, + read_timeout_seconds, + progress_callback, + meta=cast("RequestParamsMeta | None", meta), + allow_input_required=True, + ) + if not isinstance(first, InputRequiredResult): + return first + + async def retry( + responses: InputResponses | None, + request_state: str | None, + ) -> CallToolResult | InputRequiredResult: + return await super(MCPAgentClientSession, self).call_tool( + name, + arguments, + read_timeout_seconds, + progress_callback, + input_responses=responses, + request_state=request_state, + meta=cast("RequestParamsMeta | None", meta), + allow_input_required=True, + ) + + return await run_input_required_driver( + first, + dispatch=self._dispatch_input, + retry=retry, ) - async def ping(self, read_timeout_seconds: timedelta | None = None) -> EmptyResult: + async def ping(self, read_timeout_seconds: float | None = None) -> EmptyResult: """Send a ping request to check server liveness.""" - request = PingRequest(method="ping") - return await self.send_request( - ClientRequest(request), - EmptyResult, - request_read_timeout_seconds=read_timeout_seconds, - ) + del read_timeout_seconds + return await self.send_ping() - async def read_resource( + async def read_resource_complete( self, uri: AnyUrl | str, *, - meta: dict[str, Any] | RequestParams.Meta | None = None, + meta: RequestParamsMeta | None = None, ) -> ReadResourceResult: """Read a resource with optional metadata support. Always uses our overridden send_request to ensure session terminated errors are properly detected and converted to ServerSessionTerminatedError. """ - # Convert str to AnyUrl if needed - uri_obj: AnyUrl = uri if isinstance(uri, AnyUrl) else AnyUrl(uri) - - # Always create request ourselves to ensure we go through our send_request override - params = ReadResourceRequestParams(uri=uri_obj) - - supplied_meta = meta.model_dump() if isinstance(meta, RequestParams.Meta) else meta - if supplied_meta: - params = ReadResourceRequestParams( - uri=uri_obj, _meta=RequestParams.Meta(**supplied_meta) + first = await super().read_resource(str(uri), meta=meta, allow_input_required=True) + if not isinstance(first, InputRequiredResult): + return first + + async def retry( + responses: InputResponses | None, + request_state: str | None, + ) -> ReadResourceResult | InputRequiredResult: + return await super(MCPAgentClientSession, self).read_resource( + str(uri), + input_responses=responses, + request_state=request_state, + meta=meta, + allow_input_required=True, ) - request = ReadResourceRequest(method="resources/read", params=params) - return await self.send_request(ClientRequest(request), ReadResourceResult) + return await run_input_required_driver( + first, + dispatch=self._dispatch_input, + retry=retry, + ) async def read_directory( self, @@ -728,36 +730,46 @@ async def read_directory( the closed ``ClientRequest`` union, so the bare request goes through our ``send_request`` override; wrapping it would emit union serializer warnings. """ - uri_obj: AnyUrl = uri if isinstance(uri, AnyUrl) else AnyUrl(uri) - params = DirectoryReadRequestParams(uri=uri_obj, cursor=cursor) + params = DirectoryReadRequestParams(uri=str(uri), cursor=cursor) request = DirectoryReadRequest(method="resources/directory/read", params=params) - return await self.send_request( - cast("ClientRequest", request), - ListResourcesResult, - ) + return await self.send_request(request, ListResourcesResult) - async def get_prompt( + async def get_prompt_complete( self, name: str, arguments: dict[str, str] | None = None, *, - meta: dict[str, Any] | RequestParams.Meta | None = None, + meta: RequestParamsMeta | None = None, ) -> GetPromptResult: """Get a prompt with optional metadata support. Always uses our overridden send_request to ensure session terminated errors are properly detected and converted to ServerSessionTerminatedError. """ - # Always create request ourselves to ensure we go through our send_request override - params = GetPromptRequestParams(name=name, arguments=arguments) - - supplied_meta = meta.model_dump() if isinstance(meta, RequestParams.Meta) else meta - if supplied_meta: - params = GetPromptRequestParams( - name=name, - arguments=arguments, - _meta=RequestParams.Meta(**supplied_meta), + first = await super().get_prompt( + name, + arguments, + meta=meta, + allow_input_required=True, + ) + if not isinstance(first, InputRequiredResult): + return first + + async def retry( + responses: InputResponses | None, + request_state: str | None, + ) -> GetPromptResult | InputRequiredResult: + return await super(MCPAgentClientSession, self).get_prompt( + name, + arguments, + input_responses=responses, + request_state=request_state, + meta=meta, + allow_input_required=True, ) - request = GetPromptRequest(method="prompts/get", params=params) - return await self.send_request(ClientRequest(request), GetPromptResult) + return await run_input_required_driver( + first, + dispatch=self._dispatch_input, + retry=retry, + ) diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 57af7ebbe..8fa98be00 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -18,9 +18,9 @@ from mcp import GetPromptResult, ReadResourceResult from mcp.client.session import ClientSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.session import ProgressFnT -from mcp.types import ( +from mcp_types import ( CallToolResult, CompleteResult, Completion, @@ -54,7 +54,7 @@ from fast_agent.mcp.common import SEP, create_namespaced_name, is_namespaced_name from fast_agent.mcp.gen_client import gen_client from fast_agent.mcp.helpers.content_helpers import get_text -from fast_agent.mcp.interfaces import ServerRegistryProtocol +from fast_agent.mcp.interfaces import CompletingClientSession, ServerRegistryProtocol from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from fast_agent.mcp.mcp_connection_manager import ( MCPConnectionManager, @@ -182,15 +182,14 @@ def _is_capability_probe_error(exc: Exception) -> bool: """Return True when exc indicates a server does not support a probed method.""" if isinstance(exc, NotImplementedError): return True - if isinstance(exc, McpError): - code = exc.error.code + if isinstance(exc, MCPError): + code = exc.code if code == METHOD_NOT_FOUND_ERROR_CODE: return True # Only fall back to message matching when the server omitted the error code; # if a different code is set, trust the code over the message text. if code is None: - message = exc.error.message - if isinstance(message, str) and METHOD_NOT_FOUND_MESSAGE in strip_casefold(message): + if METHOD_NOT_FOUND_MESSAGE in strip_casefold(exc.message): return True return False @@ -228,6 +227,10 @@ class ServerStatus(BaseModel): server_name: str implementation_name: str | None = None implementation_version: str | None = None + protocol_version: str | None = None + protocol_era: str | None = None + supported_protocol_versions: tuple[str, ...] = () + negotiation: str | None = None server_capabilities: ServerCapabilities | None = None client_capabilities: Mapping[str, Any] | None = None client_info_name: str | None = None @@ -248,6 +251,7 @@ class ServerStatus(BaseModel): sampling_mode: str | None = None spoofing_enabled: bool | None = None session_id: str | None = None + subscription_state: str | None = None transport_channels: TransportSnapshot | None = None skybridge: SkybridgeServerConfig | None = None mcp_skills_enabled: bool | None = None @@ -1186,7 +1190,7 @@ def _apply_skybridge_resource_contents( ) -> None: seen_mime_types: list[str] = [] for content in read_result.contents: - mime_type = content.mimeType + mime_type = content.mime_type if mime_type: seen_mime_types.append(mime_type) if mime_type == SKYBRIDGE_MIME_TYPE: @@ -1684,6 +1688,10 @@ def _apply_connection_status( if implementation is not None: status.implementation_name = implementation.name status.implementation_version = implementation.version + status.protocol_version = server_conn.protocol_version + status.protocol_era = server_conn.protocol_era + status.supported_protocol_versions = server_conn.supported_protocol_versions + status.negotiation = server_conn.negotiation status.server_capabilities = server_conn.server_capabilities status.mcp_skills_enabled = server_supports_mcp_skills(server_conn.server_capabilities) @@ -1700,6 +1708,7 @@ def _apply_connection_status( status.instructions_available = server_conn.server_instructions_available status.instructions_enabled = server_conn.server_instructions_enabled status.instructions_included = bool(server_conn.server_instructions) + status.subscription_state = server_conn.subscription_state self._apply_ping_status(status, server_conn) self._apply_session_status(status, server_conn) @@ -1952,14 +1961,23 @@ async def _execute_session_method( progress_callback: ProgressFnT | None, ) -> R: try: - method = getattr(client, method_name) - kwargs = self._server_method_kwargs(method_name, method_args) - if method_name == "call_tool" and progress_callback: - result = await method(progress_callback=progress_callback, **kwargs) - else: - result = await method(**kwargs) + if method_name in {"call_tool", "read_resource", "get_prompt"}: + if not isinstance(client, CompletingClientSession): + raise TypeError("MCP session factory must provide completed MRTR operations") + kwargs = self._server_method_kwargs(method_name, method_args) + if method_name == "call_tool": + result = await client.call_tool_complete( + progress_callback=progress_callback, + **kwargs, + ) + elif method_name == "read_resource": + result = await client.read_resource_complete(**kwargs) + else: + result = await client.get_prompt_complete(**kwargs) + return cast("R", result) - return result + method = getattr(client, method_name) + return cast("R", await method(**self._server_method_kwargs(method_name, method_args))) except (ConnectionError, ServerSessionTerminatedError): raise except Exception as e: @@ -2352,7 +2370,7 @@ async def call_tool( if server_name is None: logger.error(f"Error: Tool '{name}' not found") return CallToolResult( - isError=True, + is_error=True, content=[TextContent(type="text", text=f"Tool '{name}' not found")], ) @@ -2416,7 +2434,7 @@ async def call_tool( "arguments": arguments, }, error_factory=lambda msg: CallToolResult( - isError=True, content=[TextContent(type="text", text=msg)] + is_error=True, content=[TextContent(type="text", text=msg)] ), progress_callback=progress_callback, ) @@ -2501,7 +2519,7 @@ def _permission_denied_message( @staticmethod def _tool_error_result(message: str) -> CallToolResult: return CallToolResult( - isError=True, + is_error=True, content=[TextContent(type="text", text=message)], ) @@ -2556,7 +2574,7 @@ async def _complete_tool_execution( tool_call_id: str, tool_use_id: str | None, ) -> None: - completion_state = "completed" if not result.isError else "failed" + completion_state = "completed" if not result.is_error else "failed" logger.info( "Tool call completed", data=build_progress_payload( @@ -2587,18 +2605,18 @@ async def _notify_tool_complete( tool_call_id=tool_call_id, has_content=content is not None, content_count=len(content) if content else 0, - is_error=result.isError, + is_error=result.is_error, ) error_text = None - if result.isError and content: + if result.is_error and content: text_parts = [text for c in content if (text := get_text(c))] error_text = "\n".join(text_parts) if text_parts else None content = None await active_tool_handler.on_tool_complete( tool_call_id, - not result.isError, + not result.is_error, content, error_text, ) @@ -3015,6 +3033,10 @@ async def _handle_tool_list_changed(self, server_name: str) -> None: # Refresh the tools for this server await self._refresh_server_tools(server_name) + async def _refresh_server_resources(self, server_name: str) -> None: + _, skybridge_config = await self._evaluate_skybridge_for_server(server_name) + self._skybridge_configs[server_name] = skybridge_config + async def _refresh_server_tools(self, server_name: str) -> None: """ Refresh the tools for a specific server. @@ -3157,11 +3179,11 @@ async def _execute_resource_read( ) try: - uri_obj = AnyUrl(uri) + uri_value = str(AnyUrl(uri)) except Exception as e: raise ValueError(f"Invalid {noun.lower()} URI: {uri}. Error: {e}") from e - method_args: dict[str, Any] = {"uri": uri_obj} + method_args: dict[str, Any] = {"uri": uri_value} if extra_args: method_args.update(extra_args) @@ -3304,10 +3326,10 @@ async def _list_resource_templates_from_server( operation_name="", method_name="list_resource_templates", method_args={}, - error_factory=lambda _: ListResourceTemplatesResult(resourceTemplates=[]), + error_factory=lambda _: ListResourceTemplatesResult(resource_templates=[]), ) - return result.resourceTemplates + return result.resource_templates async def list_resources(self, server_name: str | None = None) -> dict[str, list[str]]: """ diff --git a/src/fast_agent/mcp/mcp_connection_manager.py b/src/fast_agent/mcp/mcp_connection_manager.py index 0293bb65e..8d6716d1e 100644 --- a/src/fast_agent/mcp/mcp_connection_manager.py +++ b/src/fast_agent/mcp/mcp_connection_manager.py @@ -10,27 +10,26 @@ from collections.abc import Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, NoReturn, Protocol, runtime_checkable from urllib.parse import urlsplit -import httpx +import httpx2 from anyio import CancelScope, Event, Lock, create_task_group from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx import HTTPStatusError +from httpx2 import HTTPStatusError from mcp import ClientSession +from mcp.client._probe import negotiate_auto +from mcp.client.sse import sse_client from mcp.client.stdio import ( StdioServerParameters, get_default_environment, ) -from mcp.client.streamable_http import GetSessionIdCallback -from mcp.shared._httpx_utils import ( - MCP_DEFAULT_SSE_READ_TIMEOUT, - MCP_DEFAULT_TIMEOUT, - create_mcp_http_client, -) -from mcp.types import Implementation, JSONRPCMessage, ServerCapabilities +from mcp.client.streamable_http import streamable_http_client +from mcp.client.subscriptions import SubscriptionLost, listen +from mcp.shared.exceptions import MCPError +from mcp_types import Implementation, JSONRPCMessage, ServerCapabilities from fast_agent.config import MCPServerSettings from fast_agent.context_dependent import ContextDependent @@ -48,9 +47,7 @@ OAuthFlowCancelledError, build_oauth_provider, ) -from fast_agent.mcp.sse_tracking import tracking_sse_client from fast_agent.mcp.stdio_tracking_simple import tracking_stdio_client -from fast_agent.mcp.streamable_http_tracking import tracking_streamablehttp_client from fast_agent.mcp.transport_tracking import TransportChannelMetrics from fast_agent.utils.commandline import join_commandline from fast_agent.utils.count_display import format_count @@ -72,7 +69,7 @@ class PingableClientSession(Protocol): """Client session capability used by the optional keepalive loop.""" - async def ping(self, read_timeout_seconds: timedelta | None = None) -> object: ... + async def ping(self, read_timeout_seconds: float | None = None) -> object: ... def _pingable_session(session: object | None) -> PingableClientSession | None: @@ -125,7 +122,7 @@ def _add_none_to_context(context_manager): @asynccontextmanager async def _managed_http_transport_context( - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, transport_context: AbstractAsyncContextManager, ): """Own an HTTP client for a transport context built from that client.""" @@ -351,32 +348,32 @@ def create_transport_context( if config.transport == "sse": if not config.url: raise ValueError(f"Server '{server_name}' uses sse transport but no url is specified") - return tracking_sse_client( - config.url, - prepared_auth.headers, - sse_read_timeout=config.read_transport_sse_timeout_seconds, - auth=prepared_auth.oauth_provider, + return _add_none_to_context( + sse_client( + config.url, + prepared_auth.headers, + sse_read_timeout=config.read_transport_sse_timeout_seconds, + auth=prepared_auth.oauth_provider, + ) ) if config.transport == "http": if not config.url: raise ValueError(f"Server '{server_name}' uses http transport but no url is specified") timeout = None if config.http_timeout_seconds is not None or config.http_read_timeout_seconds is not None: - timeout = httpx.Timeout( - config.http_timeout_seconds or MCP_DEFAULT_TIMEOUT, - read=config.http_read_timeout_seconds or MCP_DEFAULT_SSE_READ_TIMEOUT, + timeout = httpx2.Timeout( + config.http_timeout_seconds or 30, + read=config.http_read_timeout_seconds or 300, ) - http_client = create_mcp_http_client( + http_client = httpx2.AsyncClient( headers=prepared_auth.headers, auth=prepared_auth.oauth_provider, timeout=timeout, + follow_redirects=True, ) return _managed_http_transport_context( http_client, - tracking_streamablehttp_client( - config.url, - http_client=http_client, - ), + _add_none_to_context(streamable_http_client(config.url, http_client=http_client)), ) raise ValueError(f"Unsupported transport: {config.transport}") @@ -398,7 +395,7 @@ def __init__( tuple[ MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage], - GetSessionIdCallback | None, + Callable[[], str | None] | None, ] ], ], @@ -423,13 +420,18 @@ def __init__( self.server_instructions: str | None = None self.server_capabilities: ServerCapabilities | None = None self.server_implementation: Implementation | None = None + self.protocol_version: str | None = None + self.protocol_era: str | None = None + self.supported_protocol_versions: tuple[str, ...] = () + self.negotiation: str | None = None + self.subscription_state: str | None = None self.client_capabilities: dict | None = None self.server_instructions_available: bool = False self.server_instructions_enabled: bool = ( server_config.include_instructions if server_config else True ) self.session_id: str | None = None - self._get_session_id_cb: GetSessionIdCallback | None = None + self._get_session_id_cb: Callable[[], str | None] | None = None self.transport_metrics: TransportChannelMetrics | None = None self._ping_ok_count = 0 self._ping_fail_count = 0 @@ -475,13 +477,18 @@ async def initialize_session(self) -> None: Must be called within an async context. """ assert self.session, "Session must be created before initialization" - result = await self.session.initialize() - - self.server_capabilities = result.capabilities - # InitializeResult exposes server info via `serverInfo`; keep fallback for older fields - self.server_implementation = result.serverInfo + await negotiate_auto(self.session) + self.protocol_version = self.session.protocol_version + discover_result = self.session.discover_result + self.protocol_era = "modern" if discover_result is not None else "legacy" + self.negotiation = "discover" if discover_result is not None else "initialize" + self.supported_protocol_versions = ( + tuple(discover_result.supported_versions) if discover_result is not None else () + ) + self.server_capabilities = self.session.server_capabilities + self.server_implementation = self.session.server_info - raw_instructions = result.instructions + raw_instructions = self.session.instructions self.server_instructions_available = bool(raw_instructions) # Store instructions if provided by the server and enabled in config @@ -596,11 +603,7 @@ def create_session( Create a new session instance for this server connection. """ - read_timeout = ( - timedelta(seconds=self.server_config.read_timeout_seconds) - if self.server_config.read_timeout_seconds - else None - ) + read_timeout = self.server_config.read_timeout_seconds session = self._client_session_factory( read_stream, @@ -623,13 +626,9 @@ async def _run_ping_loop(server_conn: ServerConnection) -> None: max_missed = server_conn.server_config.max_missed_pings missed = 0 - read_timeout = ( - timedelta(seconds=server_conn.server_config.read_timeout_seconds) - if server_conn.server_config.read_timeout_seconds - else None - ) + read_timeout = server_conn.server_config.read_timeout_seconds if read_timeout is None: - read_timeout = timedelta(seconds=interval) + read_timeout = float(interval) while not server_conn._shutdown_event.is_set(): await asyncio.sleep(interval) @@ -787,7 +786,7 @@ async def _run_server_lifecycle(server_conn: ServerConnection) -> None: def _refresh_server_session_id( server_conn: ServerConnection, - get_session_id_cb: GetSessionIdCallback | None, + get_session_id_cb: Callable[[], str | None] | None, ) -> None: if get_session_id_cb is not None: try: @@ -800,23 +799,74 @@ def _refresh_server_session_id( async def _wait_for_shutdown_with_optional_ping(server_conn: ServerConnection) -> None: + subscription_task: asyncio.Task[None] | None = None + if server_conn.protocol_era == "modern" and server_conn.server_config.transport == "http": + subscription_task = asyncio.create_task(_run_subscription_loop(server_conn)) + elif server_conn.protocol_era == "modern": + server_conn.subscription_state = "unsupported" if not _ping_loop_enabled(server_conn): - await server_conn.wait_for_shutdown_request() - return - - ping_task = asyncio.create_task(_run_ping_loop(server_conn)) + ping_task = None + else: + ping_task = asyncio.create_task(_run_ping_loop(server_conn)) try: await server_conn.wait_for_shutdown_request() finally: - if not ping_task.done(): + if ping_task is not None and not ping_task.done(): ping_task.cancel() - with suppress(asyncio.CancelledError): - await ping_task + if subscription_task is not None and not subscription_task.done(): + subscription_task.cancel() + if ping_task is not None: + with suppress(asyncio.CancelledError): + await ping_task + if subscription_task is not None: + with suppress(asyncio.CancelledError): + await subscription_task + + +async def _run_subscription_loop(server_conn: ServerConnection) -> None: + session = server_conn.session + if not isinstance(session, MCPAgentClientSession): + server_conn.subscription_state = "unsupported" + return + delay = 0.25 + while not server_conn._shutdown_event.is_set(): + try: + async with listen( + session, + tools_list_changed=True, + prompts_list_changed=True, + resources_list_changed=True, + ) as subscription: + server_conn.subscription_state = "open" + delay = 0.25 + async for event in subscription: + await session.handle_subscription_event(event) + server_conn.subscription_state = "closed" + except SubscriptionLost: + server_conn.subscription_state = "error" + except MCPError as exc: + if exc.code == -32601: + server_conn.subscription_state = "unsupported" + return + server_conn.subscription_state = "error" + except asyncio.CancelledError: + raise + except Exception: + server_conn.subscription_state = "error" + logger.debug( + "%s: subscription stream failed", + server_conn.server_name, + exc_info=True, + ) + if server_conn._shutdown_event.is_set(): + return + await asyncio.sleep(delay) + delay = min(delay * 2, 5) def _ping_loop_enabled(server_conn: ServerConnection) -> bool: interval = server_conn.server_config.ping_interval_seconds - return bool(interval and interval > 0) + return server_conn.protocol_era == "legacy" and bool(interval and interval > 0) def _handle_shutdown_cleanup_error( @@ -1366,12 +1416,14 @@ def _persistent_sse_transport_context( transport_metrics=transport_metrics, suppress_transport_errors=self._suppress_mcp_sse_errors, ) - return tracking_sse_client( - config.url, - headers, - sse_read_timeout=config.read_transport_sse_timeout_seconds, - auth=oauth_auth, - channel_hook=channel_hook, + del channel_hook + return _add_none_to_context( + sse_client( + config.url, + headers, + sse_read_timeout=config.read_transport_sse_timeout_seconds, + auth=oauth_auth, + ) ) def _persistent_http_transport_context( @@ -1399,18 +1451,16 @@ def _persistent_http_transport_context( transport_metrics=transport_metrics, suppress_transport_errors=self._suppress_mcp_streamable_http_errors, ) - http_client = create_mcp_http_client( + del channel_hook + http_client = httpx2.AsyncClient( headers=headers, auth=oauth_auth, timeout=self._http_timeout(config), + follow_redirects=True, ) return _managed_http_transport_context( http_client, - tracking_streamablehttp_client( - config.url, - http_client=http_client, - channel_hook=channel_hook, - ), + _add_none_to_context(streamable_http_client(config.url, http_client=http_client)), ) def _prepare_persistent_http_transport_auth( @@ -1449,12 +1499,12 @@ def _prepare_persistent_http_transport_auth( ) @staticmethod - def _http_timeout(config: MCPServerSettings) -> httpx.Timeout | None: + def _http_timeout(config: MCPServerSettings) -> httpx2.Timeout | None: if config.http_timeout_seconds is None and config.http_read_timeout_seconds is None: return None - return httpx.Timeout( - config.http_timeout_seconds or MCP_DEFAULT_TIMEOUT, - read=config.http_read_timeout_seconds or MCP_DEFAULT_SSE_READ_TIMEOUT, + return httpx2.Timeout( + config.http_timeout_seconds or 30, + read=config.http_read_timeout_seconds or 300, ) async def _launch_and_wait_for_server( diff --git a/src/fast_agent/mcp/mcp_content.py b/src/fast_agent/mcp/mcp_content.py index e21cc7f4e..64a4a851e 100644 --- a/src/fast_agent/mcp/mcp_content.py +++ b/src/fast_agent/mcp/mcp_content.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Protocol, runtime_checkable -from mcp.types import ( +from mcp_types import ( Annotations, BlobResourceContents, ContentBlock, @@ -21,7 +21,6 @@ TextContent, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.mcp.message_roles import MessageRole from fast_agent.mcp.mime_utils import ( @@ -104,7 +103,7 @@ def MCPImage( return { "role": role, "content": ImageContent( - type="image", data=b64_data, mimeType=mime_type, annotations=annotations + type="image", data=b64_data, mime_type=mime_type, annotations=annotations ), } @@ -141,18 +140,18 @@ def MCPFile( binary_data = path.read_bytes() b64_data = base64.b64encode(binary_data).decode("ascii") - resource = BlobResourceContents(uri=AnyUrl(uri), blob=b64_data, mimeType=mime_type) + resource = BlobResourceContents(uri=uri, blob=b64_data, mime_type=mime_type) else: # Read as text try: text_data = path.read_text(encoding="utf-8") - resource = TextResourceContents(uri=AnyUrl(uri), text=text_data, mimeType=mime_type) + resource = TextResourceContents(uri=uri, text=text_data, mime_type=mime_type) except UnicodeDecodeError: # Fallback to binary if text read fails binary_data = path.read_bytes() b64_data = base64.b64encode(binary_data).decode("ascii") resource = BlobResourceContents( - uri=AnyUrl(uri), blob=b64_data, mimeType=mime_type or "application/octet-stream" + uri=uri, blob=b64_data, mime_type=mime_type or "application/octet-stream" ) return { diff --git a/src/fast_agent/mcp/oauth_client.py b/src/fast_agent/mcp/oauth_client.py index 12d70a378..43a56f19b 100644 --- a/src/fast_agent/mcp/oauth_client.py +++ b/src/fast_agent/mcp/oauth_client.py @@ -37,12 +37,12 @@ handle_registration_response, should_use_client_metadata_url, ) -from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, OAuthToken, ) +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from pydantic import AnyUrl from rich.text import Text @@ -57,7 +57,7 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator - import httpx + import httpx2 as httpx from fast_agent.config import MCPServerSettings @@ -695,7 +695,7 @@ async def async_auth_flow( if not self._initialized: await self._initialize() # pragma: no cover - self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) + self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER) refresh_request = await self._refresh_request_if_needed() if refresh_request is not None: diff --git a/src/fast_agent/mcp/prompt.py b/src/fast_agent/mcp/prompt.py index b08b6fe53..6609f5fdb 100644 --- a/src/fast_agent/mcp/prompt.py +++ b/src/fast_agent/mcp/prompt.py @@ -7,7 +7,7 @@ from pathlib import Path from mcp import CallToolRequest -from mcp.types import ContentBlock, PromptMessage, ReadResourceResult, ResourceContents +from mcp_types import ContentBlock, PromptMessage, ReadResourceResult, ResourceContents from fast_agent.mcp.mcp_content import Assistant, MCPPrompt, User from fast_agent.mcp.message_roles import MessageRole diff --git a/src/fast_agent/mcp/prompt_message_extended.py b/src/fast_agent/mcp/prompt_message_extended.py index 1f625d956..0f9c6bbb2 100644 --- a/src/fast_agent/mcp/prompt_message_extended.py +++ b/src/fast_agent/mcp/prompt_message_extended.py @@ -1,7 +1,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolResult, ContentBlock, diff --git a/src/fast_agent/mcp/prompt_metadata.py b/src/fast_agent/mcp/prompt_metadata.py index 8de9b79e1..b45204e8d 100644 --- a/src/fast_agent/mcp/prompt_metadata.py +++ b/src/fast_agent/mcp/prompt_metadata.py @@ -1,6 +1,6 @@ from typing import Any -from mcp.types import GetPromptResult +from mcp_types import GetPromptResult _NAMESPACED_NAME_KEY = "fast_agent.namespaced_name" _ARGUMENTS_KEY = "fast_agent.arguments" diff --git a/src/fast_agent/mcp/prompt_render.py b/src/fast_agent/mcp/prompt_render.py index f556acf00..9e6d81e67 100644 --- a/src/fast_agent/mcp/prompt_render.py +++ b/src/fast_agent/mcp/prompt_render.py @@ -4,7 +4,7 @@ from collections.abc import Sequence -from mcp.types import BlobResourceContents, ContentBlock, TextResourceContents +from mcp_types import BlobResourceContents, ContentBlock, TextResourceContents from fast_agent.mcp.helpers.content_helpers import ( get_resource_uri, @@ -31,7 +31,7 @@ def render_content_blocks(content_blocks: Sequence[ContentBlock]) -> str: image = content image_data = image.data data_size = len(image_data) if image_data else 0 - mime_type = image.mimeType + mime_type = image.mime_type image_info = f"[IMAGE: {mime_type}, {data_size} bytes]" rendered_parts.append(image_info) @@ -45,7 +45,7 @@ def render_content_blocks(content_blocks: Sequence[ContentBlock]) -> str: # Handle text resources text = resource.text text_length = len(text) - mime_type = resource.mimeType or "text/plain" + mime_type = resource.mime_type or "text/plain" # Preview with truncation for long content preview = text[:300] + ("..." if text_length > 300 else "") @@ -58,7 +58,7 @@ def render_content_blocks(content_blocks: Sequence[ContentBlock]) -> str: # Handle blob resources (binary data) blob = resource.blob blob_length = len(blob) if blob else 0 - mime_type = resource.mimeType or "application/octet-stream" + mime_type = resource.mime_type or "application/octet-stream" resource_info = f"[EMBEDDED BLOB RESOURCE: {mime_type}, {uri}, {blob_length} bytes]" rendered_parts.append(resource_info) diff --git a/src/fast_agent/mcp/prompt_serialization.py b/src/fast_agent/mcp/prompt_serialization.py index 580c198b3..6e6fabbca 100644 --- a/src/fast_agent/mcp/prompt_serialization.py +++ b/src/fast_agent/mcp/prompt_serialization.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import cast -from mcp.types import ( +from mcp_types import ( AudioContent, EmbeddedResource, ImageContent, @@ -37,7 +37,6 @@ RESOURCE_DELIMITER, USER_DELIMITER, ) -from fast_agent.mcp.resource_utils import to_any_url from fast_agent.types import PromptMessageExtended from fast_agent.utils.text import strip_casefold @@ -455,8 +454,8 @@ def _legacy_resource_from_line(line_stripped: str) -> EmbeddedResource | None: return EmbeddedResource( type="resource", resource=TextResourceContents( - uri=to_any_url(resource_uri), - mimeType="text/plain", + uri=resource_uri, + mime_type="text/plain", text="", ), ) diff --git a/src/fast_agent/mcp/prompts/prompt_helpers.py b/src/fast_agent/mcp/prompts/prompt_helpers.py index 0345cffd7..9bb8e0e1b 100644 --- a/src/fast_agent/mcp/prompts/prompt_helpers.py +++ b/src/fast_agent/mcp/prompts/prompt_helpers.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING -from mcp.types import PromptMessage +from mcp_types import PromptMessage from fast_agent.mcp.helpers.content_helpers import get_text diff --git a/src/fast_agent/mcp/prompts/prompt_load.py b/src/fast_agent/mcp/prompts/prompt_load.py index d5fa0ad09..66c9008e6 100644 --- a/src/fast_agent/mcp/prompts/prompt_load.py +++ b/src/fast_agent/mcp/prompts/prompt_load.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any -from mcp.types import ( +from mcp_types import ( ContentBlock, EmbeddedResource, PromptMessage, diff --git a/src/fast_agent/mcp/prompts/prompt_server.py b/src/fast_agent/mcp/prompts/prompt_server.py index 24c081217..0d14d08a8 100644 --- a/src/fast_agent/mcp/prompts/prompt_server.py +++ b/src/fast_agent/mcp/prompts/prompt_server.py @@ -19,7 +19,7 @@ from fastmcp.prompts import Message, Prompt, PromptArgument, PromptResult from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources import FileResource -from mcp.types import BlobResourceContents, EmbeddedResource, ImageContent, PromptMessage +from mcp_types import BlobResourceContents, EmbeddedResource, ImageContent, PromptMessage from pydantic import AnyUrl, Field from fast_agent.mcp import mime_utils, resource_utils @@ -62,9 +62,9 @@ def convert_to_fastmcp_messages( content = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("resource://fast-agent/prompt-image"), + uri="resource://fast-agent/prompt-image", blob=content.data, - mimeType=content.mimeType, + mime_type=content.mime_type, ), ) result.append(Message(content=content, role=role)) diff --git a/src/fast_agent/mcp/resource_utils.py b/src/fast_agent/mcp/resource_utils.py index 03fcfdcc4..0ccab521b 100644 --- a/src/fast_agent/mcp/resource_utils.py +++ b/src/fast_agent/mcp/resource_utils.py @@ -1,9 +1,9 @@ import base64 -from contextlib import suppress from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlsplit -from mcp.types import ( +from mcp_types import ( BlobResourceContents, EmbeddedResource, ImageContent, @@ -77,9 +77,9 @@ def to_any_url(value: str | AnyUrl) -> AnyUrl: return _ANY_URL_ADAPTER.validate_python(value) -def create_resource_uri(path: str) -> AnyUrl: +def create_resource_uri(path: str) -> str: """Create a resource URI from a path""" - return to_any_url(f"resource://fast-agent/{Path(path).name}") + return f"resource://fast-agent/{Path(path).name}" def create_embedded_resource( @@ -94,7 +94,7 @@ def create_embedded_resource( type="resource", resource=BlobResourceContents( uri=resource_uri_str, - mimeType=mime_type, + mime_type=mime_type, blob=content, ), ) @@ -102,7 +102,7 @@ def create_embedded_resource( type="resource", resource=TextResourceContents( uri=resource_uri_str, - mimeType=mime_type, + mime_type=mime_type, text=content, ), ) @@ -113,26 +113,12 @@ def create_image_content(data: str, mime_type: str) -> ImageContent: return ImageContent( type="image", data=data, - mimeType=mime_type, + mime_type=mime_type, ) -def extract_title_from_uri(uri: AnyUrl) -> str: +def extract_title_from_uri(uri: AnyUrl | str) -> str: """Extract a readable title from a URI.""" - # Simple attempt to get filename from path uri_str = str(uri) - with suppress(Exception): - # For HTTP(S) URLs - if uri.scheme in ("http", "https"): - # Get the last part of the path - path = uri.path or "" - path_parts = path.split("/") if path else [] - filename = next((p for p in reversed(path_parts) if p), "") - return filename if filename else uri_str - - # For file URLs or other schemes - if uri.path: - return Path(uri.path).name - - # Fallback to the full URI if parsing fails - return uri_str + path = urlsplit(uri_str).path + return Path(path).name if path else uri_str diff --git a/src/fast_agent/mcp/sampling.py b/src/fast_agent/mcp/sampling.py index 3763175b1..1e4b89091 100644 --- a/src/fast_agent/mcp/sampling.py +++ b/src/fast_agent/mcp/sampling.py @@ -6,9 +6,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable -from mcp import ClientSession -from mcp.shared.context import RequestContext -from mcp.types import ( +from mcp.client.session import ClientRequestContext +from mcp_types import ( CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, @@ -97,7 +96,7 @@ def _current_app_context() -> "Context | None": return None -def _sampling_server_name(context: RequestContext[ClientSession, Any]) -> str: +def _sampling_server_name(context: ClientRequestContext) -> str: session = context.session if isinstance(session, _NamedSamplingSession) and session.session_server_name: return session.session_server_name @@ -130,7 +129,7 @@ def resolve_auto_sampling_enabled(app_context: "Context | None") -> bool: return app_context.config.auto_sampling -def _configured_sampling_model(context: RequestContext[ClientSession, Any]) -> str | None: +def _configured_sampling_model(context: ClientRequestContext) -> str | None: server_config = get_server_config(context) if server_config and server_config.sampling: return server_config.sampling.model @@ -138,7 +137,7 @@ def _configured_sampling_model(context: RequestContext[ClientSession, Any]) -> s def _agent_sampling_overrides( - context: RequestContext[ClientSession, Any], + context: ClientRequestContext, ) -> tuple[str | None, str | None]: from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession @@ -172,7 +171,7 @@ def _default_sampling_model(app_context: Any | None) -> str | None: def _select_sampling_model( - context: RequestContext[ClientSession, Any], + context: ClientRequestContext, ) -> _SamplingModelSelection: app_context = _current_app_context() model = _configured_sampling_model(context) @@ -204,19 +203,19 @@ def _sampling_response( role=llm_response.role, content=content_blocks, model=model, - stopReason="toolUse", + stop_reason="toolUse", ) return CreateMessageResult( role=llm_response.role, content=TextContent(type="text", text=llm_response.first_text()), model=model, - stopReason=LlmStopReason.END_TURN.value, + stop_reason=LlmStopReason.END_TURN.value, ) async def sample( - context: RequestContext[ClientSession, Any], params: CreateMessageRequestParams + context: ClientRequestContext, params: CreateMessageRequestParams ) -> CreateMessageResult | CreateMessageResultWithTools: """ Handle sampling requests from the MCP protocol using SamplingConverter. @@ -300,7 +299,7 @@ def sampling_agent_config( """ # Use systemPrompt from params if available, otherwise use default instruction = "You are a helpful AI Agent." - if params and params.systemPrompt is not None: - instruction = params.systemPrompt + if params and params.system_prompt is not None: + instruction = params.system_prompt return AgentConfig(name="sampling_agent", instruction=instruction) diff --git a/src/fast_agent/mcp/server/harness_adapter.py b/src/fast_agent/mcp/server/harness_adapter.py index 2fcb3e59e..7e3eff9b8 100644 --- a/src/fast_agent/mcp/server/harness_adapter.py +++ b/src/fast_agent/mcp/server/harness_adapter.py @@ -11,7 +11,7 @@ from fastmcp import Context as MCPContext # noqa: TC002 - FastMCP inspects tool annotations. from fastmcp.server.dependencies import get_access_token, get_context from fastmcp.tools import Tool -from mcp.types import TextContent +from mcp_types import TextContent from pydantic import PrivateAttr from fast_agent.core.agent_tool_shape import ( diff --git a/src/fast_agent/mcp/sse_tracking.py b/src/fast_agent/mcp/sse_tracking.py index 75ee1fac7..a2ed1e43a 100644 --- a/src/fast_agent/mcp/sse_tracking.py +++ b/src/fast_agent/mcp/sse_tracking.py @@ -1,314 +1,37 @@ -"""SSE transport wrapper that emits channel events for UI display.""" - from __future__ import annotations -import logging -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager -from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qs, urljoin, urlparse - -import anyio -import httpx -import mcp.types as types -from httpx_sse import aconnect_sse -from httpx_sse._exceptions import SSEError -from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client -from mcp.shared.message import SessionMessage -from fast_agent.mcp.http_errors import format_http_error_detail -from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName, EventType -from fast_agent.utils.text import strip_casefold +from mcp.client.sse import sse_client if TYPE_CHECKING: - from anyio.abc import TaskStatus - from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + import httpx2 -logger = logging.getLogger(__name__) +from fast_agent.mcp.transport_tracking import ChannelEvent ChannelHook = Callable[[ChannelEvent], None] -@dataclass(slots=True) -class _TrackingSSERuntime: - url: str - channel_hook: ChannelHook | None - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - session_id: str | None = None - - def get_session_id(self) -> str | None: - return self.session_id - - async def sse_reader( - self, - event_source: Any, - task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED, - ) -> None: - try: - async for sse in event_source.aiter_sse(): - if sse.event == "endpoint": - task_status.started(self._endpoint_url_from_event(sse.data)) - elif sse.event == "message": - await self._handle_message_event(sse.data) - else: - _emit_channel_event( - self.channel_hook, - "get", - "keepalive", - raw_event=sse.event or "keepalive", - ) - except SSEError as sse_exc: - logger.exception("Encountered SSE exception") - _emit_channel_event(self.channel_hook, "get", "error", detail=str(sse_exc)) - raise - except Exception as exc: - logger.exception("Error in sse_reader") - _emit_channel_event(self.channel_hook, "get", "error", detail=str(exc)) - await self.read_stream_writer.send(exc) - finally: - await self.read_stream_writer.aclose() - - def _endpoint_url_from_event(self, data: str) -> str: - endpoint_url = urljoin(self.url, data) - logger.debug("Received SSE endpoint URL: %s", endpoint_url) - - if not _same_origin(self.url, endpoint_url): - error_msg = f"Endpoint origin does not match connection origin: {endpoint_url}" - logger.error(error_msg) - _emit_channel_event(self.channel_hook, "get", "error", detail=error_msg) - raise ValueError(error_msg) - - self.session_id = _extract_session_id(endpoint_url) - return endpoint_url - - async def _handle_message_event(self, data: str) -> None: - try: - message = types.JSONRPCMessage.model_validate_json(data) - except Exception as exc: - logger.exception("Error parsing server message") - _emit_channel_event( - self.channel_hook, - "get", - "error", - detail="Error parsing server message", - ) - await self.read_stream_writer.send(exc) - return - - _emit_channel_event(self.channel_hook, "get", "message", message=message) - await self.read_stream_writer.send(SessionMessage(message)) - - async def post_writer(self, client: httpx.AsyncClient, endpoint_url: str) -> None: - try: - async with self.write_stream_reader: - async for session_message in self.write_stream_reader: - payload = self._payload_for_session_message(session_message) - if payload is None: - continue - - _emit_channel_event( - self.channel_hook, - "post-sse", - "message", - message=session_message.message, - ) - await self._post_payload(client, endpoint_url, payload) - except httpx.HTTPStatusError: - logger.exception("HTTP error in post_writer") - except Exception: - logger.exception("Error in post_writer") - _emit_channel_event( - self.channel_hook, - "post-sse", - "error", - detail="Error sending client message", - ) - finally: - await self.write_stream.aclose() - - @staticmethod - def _payload_for_session_message(session_message: SessionMessage) -> dict[str, Any] | None: - try: - return session_message.message.model_dump( - by_alias=True, - mode="json", - exclude_none=True, - ) - except Exception: - logger.exception("Invalid session message payload") - return None - - async def _post_payload( - self, - client: httpx.AsyncClient, - endpoint_url: str, - payload: dict[str, Any], - ) -> None: - try: - response = await client.post(endpoint_url, json=payload) - response.raise_for_status() - except httpx.HTTPStatusError as exc: - error_detail = _format_http_error(exc) - _emit_channel_event( - self.channel_hook, - "post-sse", - "error", - detail=error_detail.detail, - status_code=error_detail.status_code, - ) - raise - - -def _extract_session_id(endpoint_url: str) -> str | None: - parsed = urlparse(endpoint_url) - query_params = parse_qs(parsed.query) - for key in ("sessionId", "session_id", "session"): - values = query_params.get(key) - if values: - return values[0] - return None - - -def _emit_channel_event( - channel_hook: ChannelHook | None, - channel: ChannelName, - event_type: EventType, - *, - message: types.JSONRPCMessage | None = None, - raw_event: str | None = None, - detail: str | None = None, - status_code: int | None = None, -) -> None: - if channel_hook is None: - return - try: - channel_hook( - ChannelEvent( - channel=channel, - event_type=event_type, - message=message, - raw_event=raw_event, - detail=detail, - status_code=status_code, - ) - ) - except Exception: - logger.debug("Channel hook raised an exception", exc_info=True) - - -_format_http_error = format_http_error_detail - - -def _origin_port(scheme: str, port: int | None) -> int | None: - if scheme == "http" and port == 80: - return None - if scheme == "https" and port == 443: - return None - return port - - -def _origin_parts(url: str) -> tuple[str, str | None, int | None]: - parsed = urlparse(url) - scheme = strip_casefold(parsed.scheme) - hostname = strip_casefold(parsed.hostname) if parsed.hostname else None - return scheme, hostname, _origin_port(scheme, parsed.port) - - -def _same_origin(base_url: str, endpoint_url: str) -> bool: - return _origin_parts(base_url) == _origin_parts(endpoint_url) - - -def _raise_for_sse_status(response: httpx.Response, channel_hook: ChannelHook | None) -> None: - try: - response.raise_for_status() - except httpx.HTTPStatusError as exc: - error_detail = _format_http_error(exc) - _emit_channel_event( - channel_hook, - "get", - "error", - detail=error_detail.detail, - status_code=error_detail.status_code, - ) - raise - - @asynccontextmanager async def tracking_sse_client( url: str, headers: dict[str, Any] | None = None, + *, timeout: float = 5, - sse_read_timeout: float = 60 * 5, - httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, - auth: httpx.Auth | None = None, + sse_read_timeout: float = 300, + auth: httpx2.Auth | None = None, channel_hook: ChannelHook | None = None, -) -> AsyncGenerator[ - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - Callable[[], str | None], - ], - None, -]: - """ - Client transport for SSE with channel activity tracking. - """ - - read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception]( - 0 - ) - write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) - runtime = _TrackingSSERuntime( - url=url, - channel_hook=channel_hook, - read_stream_writer=read_stream_writer, - write_stream=write_stream, - write_stream_reader=write_stream_reader, - ) - - async with anyio.create_task_group() as tg: - try: - logger.debug("Connecting to SSE endpoint: %s", url) - async with httpx_client_factory( - headers=headers, - auth=auth, - timeout=httpx.Timeout(timeout, read=sse_read_timeout), - ) as client: - connected = False - post_connected = False - - try: - async with aconnect_sse( - client, - "GET", - url, - ) as event_source: - _raise_for_sse_status(event_source.response, channel_hook) - - _emit_channel_event(channel_hook, "get", "connect") - connected = True - - endpoint_url = await tg.start(runtime.sse_reader, event_source) - _emit_channel_event(channel_hook, "post-sse", "connect") - post_connected = True - tg.start_soon(runtime.post_writer, client, endpoint_url) - - try: - yield read_stream, write_stream, runtime.get_session_id - finally: - tg.cancel_scope.cancel() - except Exception: - raise - finally: - if connected: - _emit_channel_event(channel_hook, "get", "disconnect") - if post_connected: - _emit_channel_event(channel_hook, "post-sse", "disconnect") - finally: - await read_stream_writer.aclose() - await read_stream.aclose() - await write_stream_reader.aclose() - await write_stream.aclose() +) -> AsyncIterator[tuple[object, object, None]]: + """Compatibility adapter for the deprecated HTTP+SSE transport.""" + del channel_hook + async with sse_client( + url, + headers, + timeout=timeout, + sse_read_timeout=sse_read_timeout, + auth=auth, + ) as streams: + read_stream, write_stream = streams + yield read_stream, write_stream, None diff --git a/src/fast_agent/mcp/stdio_tracking_simple.py b/src/fast_agent/mcp/stdio_tracking_simple.py index 12135dc23..3490c4b4a 100644 --- a/src/fast_agent/mcp/stdio_tracking_simple.py +++ b/src/fast_agent/mcp/stdio_tracking_simple.py @@ -3,16 +3,12 @@ import logging from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, TextIO +from typing import TextIO from mcp.client.stdio import StdioServerParameters, stdio_client from fast_agent.mcp.transport_tracking import ChannelEvent, EventType -if TYPE_CHECKING: - from anyio.abc import ObjectReceiveStream, ObjectSendStream - from mcp.shared.message import SessionMessage - logger = logging.getLogger(__name__) ChannelHook = Callable[[ChannelEvent], None] @@ -24,9 +20,7 @@ async def tracking_stdio_client( *, channel_hook: ChannelHook | None = None, errlog: TextIO | None = None, -) -> AsyncGenerator[ - tuple[ObjectReceiveStream[SessionMessage | Exception], ObjectSendStream[SessionMessage]], None -]: +) -> AsyncGenerator[tuple[object, object], None]: """Context manager for stdio client with basic connection tracking.""" def emit_channel_event(event_type: EventType, detail: str | None = None) -> None: diff --git a/src/fast_agent/mcp/streamable_http_tracking.py b/src/fast_agent/mcp/streamable_http_tracking.py index 3745fa025..a9a61a95d 100644 --- a/src/fast_agent/mcp/streamable_http_tracking.py +++ b/src/fast_agent/mcp/streamable_http_tracking.py @@ -1,509 +1,37 @@ from __future__ import annotations -import logging -import sys -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import AsyncExitStack, asynccontextmanager -from dataclasses import dataclass +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from typing import TYPE_CHECKING -import anyio -import httpx -from httpx_sse import EventSource, ServerSentEvent, aconnect_sse -from mcp.client.streamable_http import ( - DEFAULT_RECONNECTION_DELAY_MS, - LAST_EVENT_ID, - MAX_RECONNECTION_ATTEMPTS, - RequestContext, - RequestId, - ResumptionError, - StreamableHTTPTransport, - StreamWriter, -) -from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.client.streamable_http import streamable_http_client from mcp.shared.message import SessionMessage -from mcp.types import ( - JSONRPCError, - JSONRPCMessage, - JSONRPCRequest, - JSONRPCResponse, - ProgressNotification, -) -from fast_agent.mcp.http_errors import format_http_error_detail -from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName, EventType -from fast_agent.utils.env import env_flag +from fast_agent.mcp.transport_tracking import ChannelEvent if TYPE_CHECKING: - from anyio.abc import ObjectReceiveStream, ObjectSendStream - -logger = logging.getLogger(__name__) + import httpx2 ChannelHook = Callable[[ChannelEvent], None] -@dataclass -class _GetStreamState: - last_event_id: str | None = None - retry_interval_ms: int | None = None - attempt: int = 0 - - def reconnect_delay_ms(self) -> int: - if self.retry_interval_ms is not None: - return self.retry_interval_ms - return DEFAULT_RECONNECTION_DELAY_MS - - -def _progress_trace_enabled() -> bool: - return env_flag("FAST_AGENT_TRACE_MCP_PROGRESS") - - -def _progress_trace(message: str) -> None: - if not _progress_trace_enabled(): - return - print(f"[mcp-progress-trace] {message}", file=sys.stderr, flush=True) - - -class ChannelTrackingStreamableHTTPTransport(StreamableHTTPTransport): - """Streamable HTTP transport that emits channel events before dispatching.""" - - def __init__( - self, - url: str, - *, - channel_hook: ChannelHook | None = None, - ) -> None: - super().__init__(url) - self._channel_hook = channel_hook - - def _open_sse_connection( - self, - client: httpx.AsyncClient, - method: str, - url: str, - *, - headers: dict[str, str], - ): - """Wrapper for SSE connections so tests can provide deterministic transports.""" - return aconnect_sse(client, method, url, headers=headers) - - async def _sleep_before_reconnect(self, delay_ms: int) -> None: - await anyio.sleep(delay_ms / 1000.0) - - def _emit_channel_event( - self, - channel: ChannelName, - event_type: EventType, - *, - message: JSONRPCMessage | None = None, - raw_event: str | None = None, - detail: str | None = None, - status_code: int | None = None, - ) -> None: - if self._channel_hook is None: - return - try: - self._channel_hook( - ChannelEvent( - channel=channel, - event_type=event_type, - message=message, - raw_event=raw_event, - detail=detail, - status_code=status_code, - ) - ) - except Exception: # pragma: no cover - hook errors must not break transport - logger.exception("Channel hook raised an exception") - - async def terminate_session(self, client: httpx.AsyncClient) -> None: - """Terminate the session by sending a DELETE request. - - Some providers return ``202 Accepted`` to indicate asynchronous session - cleanup; treat that as successful termination. - """ - - if not self.session_id: - return - - try: - headers = self._prepare_headers() - response = await client.delete(self.url, headers=headers) - - if response.status_code == 405: - logger.debug("Server does not allow session termination") - elif response.status_code not in (200, 202, 204): - logger.warning("Session termination failed: %s", response.status_code) - except Exception as exc: - logger.warning("Session termination failed: %s", exc) - - async def _handle_json_response( - self, - response: httpx.Response, - read_stream_writer: StreamWriter, - is_initialization: bool = False, - ) -> None: - try: - content = await response.aread() - message = JSONRPCMessage.model_validate_json(content) - - if is_initialization: - self._maybe_extract_protocol_version_from_message(message) - - self._emit_channel_event("post-json", "message", message=message) - await read_stream_writer.send(SessionMessage(message)) - except Exception as exc: # pragma: no cover - propagate to session - logger.exception("Error parsing JSON response") - await read_stream_writer.send(exc) - self._emit_channel_event("post-json", "error", detail=str(exc)) - - async def _handle_sse_event_with_channel( - self, - channel: ChannelName, - sse: ServerSentEvent, - read_stream_writer: StreamWriter, - original_request_id: RequestId | None = None, - resumption_callback: Callable[[str], Awaitable[None]] | None = None, - is_initialization: bool = False, - ) -> bool: - if sse.event != "message": - # Treat non-message events (e.g. ping) as keepalive notifications - self._emit_channel_event(channel, "keepalive", raw_event=sse.event or "keepalive") - return False - - # Handle priming events (empty data with ID) for resumability - if not sse.data: - if sse.id and resumption_callback: - await resumption_callback(sse.id) - self._emit_channel_event(channel, "keepalive", raw_event="priming") - return False - - if '"notifications/progress"' in sse.data: - _progress_trace( - f"inbound-sse channel={channel} event_id={sse.id or '-'} raw={sse.data}" - ) - - try: - message = JSONRPCMessage.model_validate_json(sse.data) - if is_initialization: - self._maybe_extract_protocol_version_from_message(message) - - if original_request_id is not None and isinstance( - message.root, (JSONRPCResponse, JSONRPCError) - ): - message.root.id = original_request_id - - if isinstance(message.root, ProgressNotification): - params = message.root.params - _progress_trace( - "parsed-progress " - f"channel={channel} " - f"token={params.progressToken!r} " - f"progress={params.progress!r} " - f"total={params.total!r} " - f"message={params.message!r}" - ) - - self._emit_channel_event(channel, "message", message=message) - await read_stream_writer.send(SessionMessage(message)) - - if sse.id and resumption_callback: - await resumption_callback(sse.id) - - return isinstance(message.root, (JSONRPCResponse, JSONRPCError)) - - except Exception as exc: # pragma: no cover - propagate to session - logger.exception("Error parsing SSE message") - await read_stream_writer.send(exc) - self._emit_channel_event(channel, "error", detail=str(exc)) - return False - - async def handle_get_stream( - self, - client: httpx.AsyncClient, - read_stream_writer: StreamWriter, - ) -> None: - state = _GetStreamState() - - while state.attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch - try: - if not self.session_id: - return - - await self._run_get_stream_once(client, read_stream_writer, state) - except Exception as exc: # pragma: no cover - non fatal stream errors - logger.debug("GET stream error: %s", exc) - state.attempt += 1 - self._emit_get_stream_error(exc) - - if state.attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover - return - - delay_ms = state.reconnect_delay_ms() - logger.info("GET stream disconnected, reconnecting in %sms...", delay_ms) - await self._sleep_before_reconnect(delay_ms) - - async def _run_get_stream_once( - self, - client: httpx.AsyncClient, - read_stream_writer: StreamWriter, - state: _GetStreamState, - ) -> None: - connected = False - try: - async with self._open_sse_connection( - client, - "GET", - self.url, - headers=self._get_stream_headers(state), - ) as event_source: - event_source.response.raise_for_status() - self._emit_channel_event("get", "connect") - connected = True - # Reset reconnection error budget once a retry successfully reconnects. - state.attempt = 0 - - async for sse in event_source.aiter_sse(): - self._record_get_stream_sse_metadata(state, sse) - await self._handle_sse_event_with_channel( - "get", - sse, - read_stream_writer, - ) - finally: - if connected: - self._emit_channel_event("get", "disconnect") - - def _get_stream_headers(self, state: _GetStreamState) -> dict[str, str]: - headers = self._prepare_headers() - if state.last_event_id: - headers[LAST_EVENT_ID] = state.last_event_id # pragma: no cover - return headers - - @staticmethod - def _record_get_stream_sse_metadata( - state: _GetStreamState, - sse: ServerSentEvent, - ) -> None: - if sse.id: - state.last_event_id = sse.id # pragma: no cover - if sse.retry is not None: - state.retry_interval_ms = sse.retry # pragma: no cover - - def _emit_get_stream_error(self, exc: Exception) -> None: - detail, status_code = self._get_stream_error_detail(exc) - self._emit_channel_event("get", "error", detail=detail, status_code=status_code) - - @staticmethod - def _get_stream_error_detail(exc: Exception) -> tuple[str, int | None]: - if not isinstance(exc, httpx.HTTPStatusError): - return str(exc), None - - error_detail = format_http_error_detail(exc) - return error_detail.detail, error_detail.status_code - - async def _handle_resumption_request( - self, - ctx: RequestContext, - ) -> None: - headers = self._prepare_headers() - if ctx.metadata and ctx.metadata.resumption_token: - headers[LAST_EVENT_ID] = ctx.metadata.resumption_token - else: # pragma: no cover - defensive - raise ResumptionError("Resumption request requires a resumption token") - - original_request_id: RequestId | None = None - if isinstance(ctx.session_message.message.root, JSONRPCRequest): - original_request_id = ctx.session_message.message.root.id - - async with aconnect_sse( - ctx.client, - "GET", - self.url, - headers=headers, - ) as event_source: - event_source.response.raise_for_status() - async for sse in event_source.aiter_sse(): - is_complete = await self._handle_sse_event_with_channel( - "resumption", - sse, - ctx.read_stream_writer, - original_request_id, - ctx.metadata.on_resumption_token_update if ctx.metadata else None, - ) - if is_complete: - await event_source.response.aclose() - break - - async def _handle_sse_response( - self, - response: httpx.Response, - ctx: RequestContext, - is_initialization: bool = False, - ) -> None: - last_event_id: str | None = None - retry_interval_ms: int | None = None - - try: - event_source = EventSource(response) - async for sse in event_source.aiter_sse(): - if sse.id: - last_event_id = sse.id - if sse.retry is not None: - retry_interval_ms = sse.retry - - is_complete = await self._handle_sse_event_with_channel( - "post-sse", - sse, - ctx.read_stream_writer, - resumption_callback=( - ctx.metadata.on_resumption_token_update if ctx.metadata else None - ), - is_initialization=is_initialization, - ) - if is_complete: - await response.aclose() - return - except Exception as exc: # pragma: no cover - propagate to session - logger.exception("Error reading SSE stream") - await ctx.read_stream_writer.send(exc) - self._emit_channel_event("post-sse", "error", detail=str(exc)) - - if last_event_id is not None: # pragma: no branch - await self._handle_reconnection_for_channel( - ctx, "post-sse", last_event_id, retry_interval_ms - ) - - async def _handle_reconnection_for_channel( - self, - ctx: RequestContext, - channel: ChannelName, - last_event_id: str, - retry_interval_ms: int | None = None, - attempt: int = 0, - ) -> None: - if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover - logger.debug( - "Max reconnection attempts (%s) exceeded", MAX_RECONNECTION_ATTEMPTS - ) # pragma: no cover - return - - delay_ms = ( - retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS - ) - await self._sleep_before_reconnect(delay_ms) - - headers = self._prepare_headers() - headers[LAST_EVENT_ID] = last_event_id - - original_request_id = None - if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.root.id - - try: - async with self._open_sse_connection( - ctx.client, - "GET", - self.url, - headers=headers, - ) as event_source: - event_source.response.raise_for_status() - logger.info("Reconnected to SSE stream") - attempt = 0 - - reconnect_last_event_id: str = last_event_id - reconnect_retry_ms = retry_interval_ms - - async for sse in event_source.aiter_sse(): - if sse.id: # pragma: no branch - reconnect_last_event_id = sse.id - if sse.retry is not None: - reconnect_retry_ms = sse.retry - - is_complete = await self._handle_sse_event_with_channel( - channel, - sse, - ctx.read_stream_writer, - original_request_id, - ctx.metadata.on_resumption_token_update if ctx.metadata else None, - ) - if is_complete: - await event_source.response.aclose() - return - - await self._handle_reconnection_for_channel( - ctx, - channel, - reconnect_last_event_id, - reconnect_retry_ms, - 0, - ) - except Exception as exc: # pragma: no cover - logger.debug("Reconnection failed: %s", exc) - self._emit_channel_event(channel, "error", detail=str(exc)) - await self._handle_reconnection_for_channel( - ctx, - channel, - last_event_id, - retry_interval_ms, - attempt + 1, - ) - - @asynccontextmanager async def tracking_streamablehttp_client( url: str, *, - http_client: httpx.AsyncClient | None = None, - terminate_on_close: bool = True, + http_client: httpx2.AsyncClient | None = None, channel_hook: ChannelHook | None = None, -) -> AsyncGenerator[ - tuple[ - ObjectReceiveStream[SessionMessage | Exception], - ObjectSendStream[SessionMessage], - Callable[[], str | None], - ], - None, -]: - """Context manager mirroring streamable_http_client with channel tracking.""" - - transport = ChannelTrackingStreamableHTTPTransport(url, channel_hook=channel_hook) - - read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception]( - 0 - ) - write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) - - client_provided = http_client is not None - client = http_client or create_mcp_http_client() - - async with anyio.create_task_group() as tg: - try: - async with AsyncExitStack() as stack: - if not client_provided: - await stack.enter_async_context(client) +) -> AsyncIterator[tuple[object, object, None]]: + """Compatibility adapter over the SDK v2 transport. - def start_get_stream() -> None: - tg.start_soon(transport.handle_get_stream, client, read_stream_writer) + SDK v2 owns protocol-era routing, HTTP channels, cancellation, and legacy + session behavior. Low-level channel callbacks are intentionally unavailable + until the SDK exposes a public diagnostics hook. + """ + del channel_hook + async with streamable_http_client(url, http_client=http_client) as streams: + read_stream, write_stream = streams + yield read_stream, write_stream, None - tg.start_soon( - transport.post_writer, - client, - write_stream_reader, - read_stream_writer, - write_stream, - start_get_stream, - tg, - ) - try: - yield read_stream, write_stream, transport.get_session_id - finally: - if transport.session_id and terminate_on_close: - await transport.terminate_session(client) - tg.cancel_scope.cancel() - finally: - await read_stream_writer.aclose() - await read_stream.aclose() - await write_stream_reader.aclose() - await write_stream.aclose() +__all__ = ["SessionMessage", "tracking_streamablehttp_client"] diff --git a/src/fast_agent/mcp/tool_execution_handler.py b/src/fast_agent/mcp/tool_execution_handler.py index 2e71af2b9..6f7609b11 100644 --- a/src/fast_agent/mcp/tool_execution_handler.py +++ b/src/fast_agent/mcp/tool_execution_handler.py @@ -7,7 +7,7 @@ from typing import Protocol, runtime_checkable -from mcp.types import ContentBlock +from mcp_types import ContentBlock @runtime_checkable diff --git a/src/fast_agent/mcp/tool_progress.py b/src/fast_agent/mcp/tool_progress.py index c5f31bdec..4d2142793 100644 --- a/src/fast_agent/mcp/tool_progress.py +++ b/src/fast_agent/mcp/tool_progress.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable - from mcp.types import ContentBlock + from mcp_types import ContentBlock else: ContentBlock = Any diff --git a/src/fast_agent/mcp/tool_result_metadata.py b/src/fast_agent/mcp/tool_result_metadata.py index b92665a04..ac0134b38 100644 --- a/src/fast_agent/mcp/tool_result_metadata.py +++ b/src/fast_agent/mcp/tool_result_metadata.py @@ -3,13 +3,13 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast from weakref import WeakKeyDictionary -from mcp.types import CallToolResult +from mcp_types import CallToolResult if TYPE_CHECKING: - from mcp.types import ContentBlock + from mcp_types import ContentBlock from fast_agent.mcp.url_elicitation_required import ( URLElicitationRequiredDisplayPayload, @@ -18,6 +18,17 @@ _FATAL_TOOL_ERROR_META_KEY = "fast_agent/fatal_tool_error" _URL_ELICITATION_META_KEY = "fast_agent/url_elicitation_required" _MEDIA_PREVIEW_META_KEY = "fast_agent/media_preview_content" +_DISPLAY_META_KEY = "fast_agent/display" + + +class ToolResultDisplayMetadata(TypedDict, total=False): + suppress_display: bool + exit_code: int + output_line_count: int + read_text_file_path: str + read_text_file_line: int + read_text_file_limit: int + transport_channel: str _OBJECT_URL_ELICITATION_METADATA: WeakKeyDictionary[ @@ -107,3 +118,22 @@ def get_tool_result_media_preview( if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): return None return cast("Sequence[ContentBlock]", value) + + +def update_tool_result_display_metadata( + result: CallToolResult, + values: ToolResultDisplayMetadata, +) -> None: + metadata = _metadata(result) + display = metadata.setdefault(_DISPLAY_META_KEY, {}) + if not isinstance(display, dict): + display = {} + metadata[_DISPLAY_META_KEY] = display + display.update(values) + + +def tool_result_display_metadata(result: CallToolResult) -> ToolResultDisplayMetadata: + value = (result.meta or {}).get(_DISPLAY_META_KEY) + if not isinstance(value, dict): + return {} + return cast("ToolResultDisplayMetadata", value) diff --git a/src/fast_agent/mcp/tool_result_truncation.py b/src/fast_agent/mcp/tool_result_truncation.py index 9b069a072..abb9b8222 100644 --- a/src/fast_agent/mcp/tool_result_truncation.py +++ b/src/fast_agent/mcp/tool_result_truncation.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mcp.types import CallToolResult, ContentBlock, TextContent +from mcp_types import CallToolResult, ContentBlock, TextContent from fast_agent.mcp.helpers.content_helpers import ( canonicalize_tool_result_content_for_llm, @@ -40,6 +40,6 @@ def truncate_tool_result_for_llm( return result.model_copy( update={ "content": content, - "structuredContent": None, + "structured_content": None, } ) diff --git a/src/fast_agent/mcp/transport_tracking.py b/src/fast_agent/mcp/transport_tracking.py index 29bf5aaa6..00cb4a524 100644 --- a/src/fast_agent/mcp/transport_tracking.py +++ b/src/fast_agent/mcp/transport_tracking.py @@ -7,7 +7,7 @@ from threading import Lock from typing import TYPE_CHECKING, Literal, cast -from mcp.types import ( +from mcp_types import ( JSONRPCError, JSONRPCMessage, JSONRPCNotification, @@ -95,17 +95,16 @@ def has_activity(self) -> bool: def _summarise_message(message: JSONRPCMessage) -> str: - root = message.root - if isinstance(root, JSONRPCRequest): - method = root.method or "" + if isinstance(message, JSONRPCRequest): + method = message.method or "" return f"request {method}" - if isinstance(root, JSONRPCNotification): - method = root.method or "" + if isinstance(message, JSONRPCNotification): + method = message.method or "" return f"notify {method}" - if isinstance(root, JSONRPCResponse): + if isinstance(message, JSONRPCResponse): return "response" - if isinstance(root, JSONRPCError): - code = getattr(root.error, "code", None) + if isinstance(message, JSONRPCError): + code = message.error.code return f"error {code}" if code is not None else "error" return "message" @@ -421,7 +420,7 @@ def _handle_stdio_event(self, event: ChannelEvent, now: datetime) -> None: def _record_response_channel(self, event: ChannelEvent) -> None: if event.message is None: return - root = event.message.root + root = event.message request_id: RequestId | None = None if isinstance(root, (JSONRPCResponse, JSONRPCError)): request_id = root.id @@ -444,7 +443,7 @@ def _tally_message_counts( sub_mode: PostMode | None = None, ) -> ActivityState: classification = self._classify_message(message) - root = message.root + root = message request_id = self._message_request_id(root) classification = self._classify_ping_exchange(classification, root, request_id) self._tally_classification(channel_key, classification, timestamp, sub_mode=sub_mode) @@ -524,7 +523,7 @@ def _register_ping(self, timestamp: datetime) -> None: def _classify_message(self, message: JSONRPCMessage | None) -> ActivityState: if message is None: return ActivityState.NONE - root = message.root + root = message method = getattr(root, "method", "") normalized_method = strip_casefold(method) if isinstance(method, str) else "" diff --git a/src/fast_agent/mcp/ui_mixin.py b/src/fast_agent/mcp/ui_mixin.py index 870ea478d..d0d713183 100644 --- a/src/fast_agent/mcp/ui_mixin.py +++ b/src/fast_agent/mcp/ui_mixin.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from mcp.types import CallToolResult, ContentBlock, EmbeddedResource +from mcp_types import CallToolResult, ContentBlock, EmbeddedResource from fast_agent.constants import MCP_UI from fast_agent.mcp.ui_modes import McpUIMode, normalize_mcp_ui_mode @@ -259,7 +259,7 @@ def _extract_ui_from_tool_results( # Recreate CallToolResult without UI blocks new_results[key] = CallToolResult( content=split_blocks.other_blocks, - isError=result.isError, + is_error=result.is_error, ) except Exception: # Pass through untouched on any error diff --git a/src/fast_agent/mcp/url_elicitation_required.py b/src/fast_agent/mcp/url_elicitation_required.py index f4b32ea4e..5f2e39321 100644 --- a/src/fast_agent/mcp/url_elicitation_required.py +++ b/src/fast_agent/mcp/url_elicitation_required.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from mcp.types import ElicitRequestURLParams +from mcp_types import ElicitRequestURLParams from pydantic import ValidationError from fast_agent.utils.text import strip_str_to_none @@ -99,6 +99,12 @@ def parse_url_elicitation_required_data(data: object) -> ParsedURLElicitationErr issues.append(f"error.data.elicitations[{index}] is invalid: {details}") continue + if strip_str_to_none(elicitation.elicitation_id) is None: + issues.append( + f"error.data.elicitations[{index}] is invalid: elicitationId must not be blank" + ) + continue + elicitations.append(elicitation) return ParsedURLElicitationErrorData(elicitations=elicitations, issues=issues) @@ -115,8 +121,8 @@ def build_url_elicitation_required_display_payload( items = [ URLElicitationDisplayItem( message=item.message, - url=item.url, - elicitation_id=item.elicitationId, + url=str(item.url), + elicitation_id=item.elicitation_id or "", ) for item in parsed.elicitations ] diff --git a/src/fast_agent/mcp_server_registry.py b/src/fast_agent/mcp_server_registry.py index 80eb331cf..3f1046aca 100644 --- a/src/fast_agent/mcp_server_registry.py +++ b/src/fast_agent/mcp_server_registry.py @@ -7,22 +7,23 @@ server initialization. """ -from collections.abc import AsyncIterator +from __future__ import annotations + from contextlib import asynccontextmanager -from datetime import timedelta from typing import TYPE_CHECKING -from mcp import ClientSession +from mcp.client._probe import negotiate_auto -from fast_agent.config import ( - MCPServerSettings, - Settings, -) from fast_agent.core.logging.logger import get_logger -from fast_agent.mcp.interfaces import ClientSessionFactory if TYPE_CHECKING: - from mcp.types import InitializeResult, ServerCapabilities + from collections.abc import AsyncIterator + + from mcp import ClientSession + from mcp_types import ServerCapabilities + + from fast_agent.config import MCPServerSettings, Settings + from fast_agent.mcp.interfaces import ClientSessionFactory logger = get_logger(__name__) @@ -49,7 +50,7 @@ def __init__( config (Settings): The Settings object containing the server configurations. config_path (str): Path to the YAML configuration file. """ - self._init_results: dict[str, InitializeResult] = {} + self._capabilities: dict[str, ServerCapabilities] = {} self._config = config self.registry = config.mcp.servers if config is not None and config.mcp is not None else {} @@ -74,8 +75,7 @@ def get_server_config(self, server_name: str) -> MCPServerSettings | None: def get_server_capabilities(self, server_name: str) -> "ServerCapabilities | None": """Return cached capabilities for a server, or None if not yet initialized.""" - init_result = self._init_results.get(server_name) - return init_result.capabilities if init_result else None + return self._capabilities.get(server_name) @asynccontextmanager async def initialize_server( @@ -121,11 +121,7 @@ async def _initialized_session(oauth_enabled: bool) -> AsyncIterator[ClientSessi ) async with transport_context as (read_stream, write_stream, _get_session_id_cb): - read_timeout = ( - timedelta(seconds=config.read_timeout_seconds) - if config.read_timeout_seconds - else None - ) + read_timeout = config.read_timeout_seconds if client_session_factory is not None: session = client_session_factory( read_stream, @@ -139,8 +135,9 @@ async def _initialized_session(oauth_enabled: bool) -> AsyncIterator[ClientSessi ) async with session: - result: InitializeResult = await session.initialize() - self._init_results[server_name] = result + await negotiate_auto(session) + if session.server_capabilities is not None: + self._capabilities[server_name] = session.server_capabilities yield session try: diff --git a/src/fast_agent/session/hydrator.py b/src/fast_agent/session/hydrator.py index 2afd6f85a..c93e8bf36 100644 --- a/src/fast_agent/session/hydrator.py +++ b/src/fast_agent/session/hydrator.py @@ -578,7 +578,7 @@ def _apply_request_settings( ) -> None: params = self._base_request_params(agent) if request_settings.max_tokens is not None: - params.maxTokens = request_settings.max_tokens + params.max_tokens = request_settings.max_tokens params.temperature = request_settings.temperature params.top_p = request_settings.top_p params.top_k = request_settings.top_k @@ -608,7 +608,7 @@ def _apply_request_settings( ) params.streaming_timeout = request_settings.streaming_timeout params.service_tier = request_settings.service_tier - params.systemPrompt = agent.instruction + params.system_prompt = agent.instruction agent.config.use_history = params.use_history agent.config.default_request_params = params.model_copy(deep=True) @@ -625,7 +625,7 @@ def _base_request_params(self, agent: AgentProtocol) -> RequestParams: if default_params is not None: return default_params.model_copy(deep=True) - return RequestParams(use_history=agent.config.use_history, systemPrompt=agent.instruction) + return RequestParams(use_history=agent.config.use_history, system_prompt=agent.instruction) def _persisted_attached_mcp_servers( self, diff --git a/src/fast_agent/session/snapshot.py b/src/fast_agent/session/snapshot.py index fdcdfd73f..8e967fa3a 100644 --- a/src/fast_agent/session/snapshot.py +++ b/src/fast_agent/session/snapshot.py @@ -961,7 +961,7 @@ def _request_settings_snapshot_from_params( return None snapshot = SessionRequestSettingsSnapshot( - max_tokens=params.maxTokens, + max_tokens=params.max_tokens, temperature=params.temperature, top_p=params.top_p, top_k=params.top_k, diff --git a/src/fast_agent/session/token_accounting_validation.py b/src/fast_agent/session/token_accounting_validation.py index 5fe8e4c04..755a34ed5 100644 --- a/src/fast_agent/session/token_accounting_validation.py +++ b/src/fast_agent/session/token_accounting_validation.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Never -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import FAST_AGENT_USAGE from fast_agent.llm.usage_tracking import UsageReport diff --git a/src/fast_agent/session/trace_export_atif.py b/src/fast_agent/session/trace_export_atif.py index 975d35d21..8e3c61454 100644 --- a/src/fast_agent/session/trace_export_atif.py +++ b/src/fast_agent/session/trace_export_atif.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, cast from urllib.parse import parse_qs, urlparse -from mcp.types import CallToolResult, ImageContent, TextContent +from mcp_types import CallToolResult, ImageContent, TextContent from fast_agent.constants import ( FAST_AGENT_PROCESS_POLL_FOLD, @@ -211,7 +211,7 @@ def _atif_content(blocks: list[object]) -> AtifContent: images = [ block for block in blocks - if isinstance(block, ImageContent) and _is_atif_image_mime(block.mimeType) + if isinstance(block, ImageContent) and _is_atif_image_mime(block.mime_type) ] texts = [block.text for block in blocks if isinstance(block, TextContent)] if not images: @@ -221,8 +221,8 @@ def _atif_content(blocks: list[object]) -> AtifContent: AtifContentPart( type="image", source=AtifImageSource( - media_type=cast("AtifImageMime", image.mimeType), - path=f"data:{image.mimeType};base64,{image.data}", + media_type=cast("AtifImageMime", image.mime_type), + path=f"data:{image.mime_type};base64,{image.data}", ), ) for image in images @@ -261,7 +261,7 @@ def _tool_result_extra( call_id: str, result: CallToolResult, ) -> dict[str, object]: - extra: dict[str, object] = {"is_error": bool(result.isError)} + extra: dict[str, object] = {"is_error": bool(result.is_error)} timing = (_json_channel_mapping(message, FAST_AGENT_TOOL_TIMING) or {}).get(call_id) if isinstance(timing, dict): timing_mapping: dict[str, object] = { diff --git a/src/fast_agent/session/trace_export_codex.py b/src/fast_agent/session/trace_export_codex.py index 6e3334c8b..ec47192be 100644 --- a/src/fast_agent/session/trace_export_codex.py +++ b/src/fast_agent/session/trace_export_codex.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Protocol, cast from urllib.parse import parse_qsl, urlencode -from mcp.types import ( +from mcp_types import ( AudioContent, CallToolRequest, CallToolResult, @@ -197,7 +197,7 @@ def _sanitize_text(sanitization: _TextSanitization | None, text: str) -> str: def _data_url(image: ImageContent) -> str: - return f"data:{image.mimeType};base64,{image.data}" + return f"data:{image.mime_type};base64,{image.data}" def _message_texts(blocks: Iterable[ContentBlock]) -> list[str]: @@ -210,9 +210,9 @@ def _user_images(blocks: Iterable[ContentBlock]) -> list[str]: def _content_mime_type(block: ContentBlock) -> str | None: if isinstance(block, (AudioContent, ImageContent, ResourceLink)): - return block.mimeType + return block.mime_type if isinstance(block, EmbeddedResource): - return block.resource.mimeType + return block.resource.mime_type return None @@ -239,7 +239,7 @@ def _embedded_text_item( if not isinstance(resource, TextResourceContents): return None - mime_type = resource.mimeType or "text/plain" + mime_type = resource.mime_type or "text/plain" if not is_text_mime_type(mime_type): return None @@ -729,7 +729,7 @@ def _tool_result_output( def _tool_result_status(result: CallToolResult) -> str: - return "error" if result.isError else "success" + return "error" if result.is_error else "success" def _object_mapping(value: object) -> dict[str, object] | None: diff --git a/src/fast_agent/skills/mcp_registry.py b/src/fast_agent/skills/mcp_registry.py index 57356cf0b..1ca17665d 100644 --- a/src/fast_agent/skills/mcp_registry.py +++ b/src/fast_agent/skills/mcp_registry.py @@ -32,7 +32,7 @@ from urllib.parse import urlparse import frontmatter -from mcp.types import BlobResourceContents, ServerCapabilities, TextResourceContents +from mcp_types import BlobResourceContents, ServerCapabilities, TextResourceContents from pydantic import AnyUrl from fast_agent.core.logging.logger import get_logger @@ -46,7 +46,7 @@ from fast_agent.skills.registry import SkillRegistry if TYPE_CHECKING: - from mcp.types import ListResourcesResult, ReadResourceResult + from mcp_types import ListResourcesResult, ReadResourceResult class McpSkillRegistryClient(Protocol): @@ -145,8 +145,7 @@ def display_name(self) -> str: def _extension_settings(capabilities: ServerCapabilities | None) -> Mapping[str, Any] | None: if capabilities is None: return None - extras = capabilities.model_extra or {} - extensions = extras.get("extensions") + extensions = capabilities.extensions if not isinstance(extensions, Mapping): return None settings = extensions.get(SKILLS_EXTENSION) @@ -156,8 +155,7 @@ def _extension_settings(capabilities: ServerCapabilities | None) -> Mapping[str, def server_supports_mcp_skills(capabilities: ServerCapabilities | None) -> bool: if capabilities is None: return False - extras = capabilities.model_extra or {} - extensions = extras.get("extensions") + extensions = capabilities.extensions if not isinstance(extensions, Mapping): return False return SKILLS_EXTENSION in extensions @@ -746,7 +744,7 @@ async def _walk_skill_directory( data={"server": server_name, "root": root_uri, "uri": child_uri}, ) continue - if resource.mimeType == DIRECTORY_MIME_TYPE: + if resource.mime_type == DIRECTORY_MIME_TYPE: await _walk_skill_directory( aggregator, server_name=server_name, @@ -775,7 +773,7 @@ async def _walk_skill_directory( data={ "server": server_name, "uri": child_uri, - "mimeType": resource.mimeType, + "mimeType": resource.mime_type, }, ) continue @@ -785,7 +783,7 @@ async def _walk_skill_directory( destination = dest_dir / PurePosixPath(relative) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(data) - cursor = listing.nextCursor + cursor = listing.next_cursor if not cursor: break @@ -991,4 +989,3 @@ def _safe_install_dir_name(name: str) -> str: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name): raise ValueError(f"Invalid MCP skill name for local install: {name}") return name - diff --git a/src/fast_agent/tools/apply_patch_tool.py b/src/fast_agent/tools/apply_patch_tool.py index 3306702e3..42049528c 100644 --- a/src/fast_agent/tools/apply_patch_tool.py +++ b/src/fast_agent/tools/apply_patch_tool.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from typing import Any, Final -from mcp.types import Tool +from mcp_types import Tool from fast_agent.utils.tool_names import matches_tool_name @@ -41,7 +41,7 @@ def build_apply_patch_tool() -> Tool: return Tool( name=APPLY_PATCH_TOOL_NAME, description=APPLY_PATCH_TOOL_DESCRIPTION, - inputSchema={ + input_schema={ "type": "object", "properties": { APPLY_PATCH_INPUT_FIELD: { diff --git a/src/fast_agent/tools/attach_media.py b/src/fast_agent/tools/attach_media.py index abce92e78..c45207968 100644 --- a/src/fast_agent/tools/attach_media.py +++ b/src/fast_agent/tools/attach_media.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Literal from urllib.parse import unquote, urlparse -from mcp.types import ( +from mcp_types import ( BlobResourceContents, ContentBlock, EmbeddedResource, @@ -17,7 +17,7 @@ ResourceLink, ) from PIL import Image -from pydantic import AnyUrl, ByteSize +from pydantic import ByteSize from fast_agent.io.path_uri import file_uri_to_path from fast_agent.llm.provider_types import Provider @@ -181,9 +181,9 @@ def build_attach_media( if source_info.kind == "link": block = ResourceLink( type="resource_link", - uri=AnyUrl(source_info.resolved_source), + uri=source_info.resolved_source, name=source_info.display_name, - mimeType=source_info.mime_type, + mime_type=source_info.mime_type, description=description, ) return AttachMediaResult( @@ -264,9 +264,9 @@ def build_attach_media_link( block = ResourceLink( type="resource_link", - uri=AnyUrl(source_info.resolved_source), + uri=source_info.resolved_source, name=source_info.display_name, - mimeType=source_info.mime_type, + mime_type=source_info.mime_type, description=description, ) return AttachMediaResult( @@ -316,14 +316,14 @@ def build_attach_media_from_bytes( encoded = base64.b64encode(data).decode("ascii") if is_image_mime_type(resolved_mime): - block = ImageContent(type="image", data=encoded, mimeType=resolved_mime) + block = ImageContent(type="image", data=encoded, mime_type=resolved_mime) else: block = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl(attachment_uri(raw_source)), + uri=attachment_uri(raw_source), blob=encoded, - mimeType=resolved_mime, + mime_type=resolved_mime, ), ) diff --git a/src/fast_agent/tools/composite_filesystem_runtime.py b/src/fast_agent/tools/composite_filesystem_runtime.py index ab53d1cd7..a0e24a8b3 100644 --- a/src/fast_agent/tools/composite_filesystem_runtime.py +++ b/src/fast_agent/tools/composite_filesystem_runtime.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.mcp.tool_result_metadata import set_fatal_tool_error from fast_agent.tools.filesystem_tool_definitions import ( @@ -13,7 +13,7 @@ from fast_agent.utils.collections import unique_preserve_order if TYPE_CHECKING: - from mcp.types import Tool + from mcp_types import Tool from fast_agent.tools.filesystem_runtime_protocol import FilesystemRuntime from fast_agent.types import RequestParams @@ -37,7 +37,7 @@ def _unsupported_tool_result(name: str) -> CallToolResult: text=message, ) ], - isError=True, + is_error=True, ), message, ) diff --git a/src/fast_agent/tools/edit_file_tool.py b/src/fast_agent/tools/edit_file_tool.py index c7ca316a1..41cd9ae9f 100644 --- a/src/fast_agent/tools/edit_file_tool.py +++ b/src/fast_agent/tools/edit_file_tool.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, Final -from mcp.types import Tool +from mcp_types import Tool from fast_agent.tools.filesystem_tool_args import ( coerce_required_string_argument, @@ -30,7 +30,7 @@ def build_edit_file_tool() -> Tool: return Tool( name=EDIT_FILE_TOOL_NAME, description=EDIT_FILE_TOOL_DESCRIPTION, - inputSchema={ + input_schema={ "type": "object", "properties": { "path": { diff --git a/src/fast_agent/tools/elicitation.py b/src/fast_agent/tools/elicitation.py index 1c8ada1c7..2aa5e1aea 100644 --- a/src/fast_agent/tools/elicitation.py +++ b/src/fast_agent/tools/elicitation.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal -from mcp.types import Tool as McpTool +from mcp_types import Tool as McpTool from pydantic import BaseModel, Field from fast_agent.constants import HUMAN_INPUT_TOOL_NAME @@ -145,7 +145,7 @@ def get_elicitation_tool() -> McpTool: "Each field may include label, help, default; numbers may include min/max; radio may include options (value/label). " "You may also add an optional message shown above the form." ), - inputSchema=_sanitized_elicitation_schema(), + input_schema=_sanitized_elicitation_schema(), ) @@ -470,5 +470,5 @@ async def elicit( "numbers support min/max; radio supports options (value/label); optional message is shown above the form." ) # Harmonize input schema with the sanitized MCP schema for provider compatibility - tool.parameters = get_elicitation_tool().inputSchema + tool.parameters = get_elicitation_tool().input_schema return tool diff --git a/src/fast_agent/tools/environment_filesystem_runtime.py b/src/fast_agent/tools/environment_filesystem_runtime.py index cd80ed08e..69df6df06 100644 --- a/src/fast_agent/tools/environment_filesystem_runtime.py +++ b/src/fast_agent/tools/environment_filesystem_runtime.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from mcp.types import CallToolResult + from mcp_types import CallToolResult from fast_agent.llm.model_info import ModelInfo from fast_agent.mcp.tool_execution_handler import ToolExecutionHandler diff --git a/src/fast_agent/tools/external_runtime_protocol.py b/src/fast_agent/tools/external_runtime_protocol.py index 059ce1e5d..c78426f8d 100644 --- a/src/fast_agent/tools/external_runtime_protocol.py +++ b/src/fast_agent/tools/external_runtime_protocol.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Protocol if TYPE_CHECKING: - from mcp.types import CallToolResult, Tool + from mcp_types import CallToolResult, Tool class ExternalRuntime(Protocol): diff --git a/src/fast_agent/tools/filesystem_runtime_base.py b/src/fast_agent/tools/filesystem_runtime_base.py index b66dadc5b..1802f6d3d 100644 --- a/src/fast_agent/tools/filesystem_runtime_base.py +++ b/src/fast_agent/tools/filesystem_runtime_base.py @@ -6,7 +6,7 @@ from contextlib import suppress from typing import TYPE_CHECKING, Any -from mcp.types import CallToolResult, ContentBlock, TextContent, Tool +from mcp_types import CallToolResult, ContentBlock, TextContent, Tool from fast_agent.llm.provider_types import Provider from fast_agent.tools.apply_patch_tool import APPLY_PATCH_TOOL_NAME, build_apply_patch_tool @@ -44,7 +44,7 @@ def text_result(message: str, *, is_error: bool) -> CallToolResult: return CallToolResult( content=[TextContent(type="text", text=message)], - isError=is_error, + is_error=is_error, ) @@ -218,12 +218,12 @@ async def _call_with_tracking( result = await method(arguments, tool_use_id) if tool_handler is not None and tool_call_id is not None: - error_text = self._extract_error_text(result, tool_name) if result.isError else None + error_text = self._extract_error_text(result, tool_name) if result.is_error else None with suppress(Exception): await tool_handler.on_tool_complete( tool_call_id, - not result.isError, - result.content if not result.isError else None, + not result.is_error, + result.content if not result.is_error else None, error_text, ) return result diff --git a/src/fast_agent/tools/filesystem_runtime_protocol.py b/src/fast_agent/tools/filesystem_runtime_protocol.py index 8ae0dd636..736b71a6a 100644 --- a/src/fast_agent/tools/filesystem_runtime_protocol.py +++ b/src/fast_agent/tools/filesystem_runtime_protocol.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from collections.abc import Sequence - from mcp.types import CallToolResult, Tool + from mcp_types import CallToolResult, Tool from fast_agent.types import RequestParams diff --git a/src/fast_agent/tools/filesystem_tool_definitions.py b/src/fast_agent/tools/filesystem_tool_definitions.py index f0cd7a124..445c60f13 100644 --- a/src/fast_agent/tools/filesystem_tool_definitions.py +++ b/src/fast_agent/tools/filesystem_tool_definitions.py @@ -4,7 +4,7 @@ from typing import Final -from mcp.types import Tool +from mcp_types import Tool ATTACH_MEDIA_TOOL_NAME: Final = "attach_media" READ_TEXT_FILE_TOOL_NAME: Final = "read_text_file" @@ -40,7 +40,7 @@ def build_attach_media_tool( "internal:// or MCP resource URIs; use get_resource for those. Use read_text_file " "for plain text/code files." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "source": { @@ -76,7 +76,7 @@ def build_read_text_file_tool() -> Tool: return Tool( name=READ_TEXT_FILE_TOOL_NAME, description="Read content from a text file. Returns the file contents as a string. ", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": { @@ -105,7 +105,7 @@ def build_write_text_file_tool() -> Tool: return Tool( name=WRITE_TEXT_FILE_TOOL_NAME, description="Write content to a text file. Creates or overwrites the file. ", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": { diff --git a/src/fast_agent/tools/filesystem_tool_specs.py b/src/fast_agent/tools/filesystem_tool_specs.py index bd5e0093d..bd89cf211 100644 --- a/src/fast_agent/tools/filesystem_tool_specs.py +++ b/src/fast_agent/tools/filesystem_tool_specs.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from mcp.types import CallToolResult, Tool + from mcp_types import CallToolResult, Tool FilesystemToolHandler = Callable[[dict[str, Any] | None, str | None], Awaitable["CallToolResult"]] diff --git a/src/fast_agent/tools/local_filesystem_runtime.py b/src/fast_agent/tools/local_filesystem_runtime.py index 71cd54233..416dc3b2e 100644 --- a/src/fast_agent/tools/local_filesystem_runtime.py +++ b/src/fast_agent/tools/local_filesystem_runtime.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.mcp.mime_utils import is_image_mime_type from fast_agent.mcp.tool_result_metadata import set_tool_result_media_preview @@ -175,7 +175,7 @@ async def attach_media( self._logger.exception("Error attaching resource") return CallToolResult( content=[TextContent(type="text", text=str(exc))], - isError=True, + is_error=True, ) self._pending_media_attachments.append(attached.block) @@ -186,7 +186,7 @@ async def attach_media( text=attach_media_staging_message(attached), ) ], - isError=False, + is_error=False, ) if is_image_mime_type(attached.mime_type): set_tool_result_media_preview(result, [attached.block]) @@ -201,7 +201,7 @@ async def apply_patch( if not isinstance(arguments, dict): return CallToolResult( content=[TextContent(type="text", text="Error: arguments must be a dict")], - isError=True, + is_error=True, ) patch_text = extract_apply_patch_input(arguments) @@ -213,7 +213,7 @@ async def apply_patch( text="Error: 'input' argument is required and must be a string", ) ], - isError=True, + is_error=True, ) stdout = io.StringIO() @@ -226,7 +226,7 @@ async def apply_patch( error_text = stderr.getvalue().strip() or str(exc) return CallToolResult( content=[TextContent(type="text", text=error_text)], - isError=True, + is_error=True, ) output = stdout.getvalue().strip() @@ -235,7 +235,7 @@ async def apply_patch( self._logger.debug("Applied local patch", base_directory=str(base_directory)) return CallToolResult( content=[TextContent(type="text", text=output)], - isError=False, + is_error=False, ) async def edit_file( @@ -256,7 +256,7 @@ async def edit_file( ), ) ], - isError=True, + is_error=True, ) resolved_path = self._resolve_path(edit_input.path) @@ -272,8 +272,8 @@ async def edit_file( is_error = structured_payload["success"] is False return CallToolResult( content=[TextContent(type="text", text=payload_text)], - structuredContent=structured_payload, - isError=is_error, + structured_content=structured_payload, + is_error=is_error, ) def metadata(self) -> dict[str, Any]: diff --git a/src/fast_agent/tools/shell_process.py b/src/fast_agent/tools/shell_process.py index e3f9fa052..0dbc05773 100644 --- a/src/fast_agent/tools/shell_process.py +++ b/src/fast_agent/tools/shell_process.py @@ -4,11 +4,12 @@ import time from collections import deque from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from typing import TYPE_CHECKING, Literal, TypedDict, cast -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.constants import FAST_AGENT_SHELL_PROCESS_METADATA +from fast_agent.mcp.tool_result_metadata import update_tool_result_display_metadata from fast_agent.tools.process_resources import ( ProcessResourceObservationState, ProcessResourceSnapshotMetadata, @@ -69,14 +70,15 @@ def process_result( metadata: ProcessResultMetadata, ) -> CallToolResult: result = CallToolResult( - isError=is_error, + is_error=is_error, content=[TextContent(type="text", text=message)], ) result.meta = {FAST_AGENT_SHELL_PROCESS_METADATA: metadata} - # Shell result rendering consumes this transient projection. Process lifecycle - # consumers read the canonical durable metadata above. if "output_line_count" in metadata: - cast("Any", result).output_line_count = metadata["output_line_count"] + update_tool_result_display_metadata( + result, + {"output_line_count": metadata["output_line_count"]}, + ) return result @@ -334,7 +336,10 @@ def build_managed_process_result( ), }, ) - cast("Any", result)._suppress_display = yielded_reason is not None or not output + update_tool_result_display_metadata( + result, + {"suppress_display": yielded_reason is not None or not output}, + ) return result if process.task.cancelled(): diff --git a/src/fast_agent/tools/shell_runtime.py b/src/fast_agent/tools/shell_runtime.py index 36330ba8e..871154414 100644 --- a/src/fast_agent/tools/shell_runtime.py +++ b/src/fast_agent/tools/shell_runtime.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast -from mcp.types import CallToolResult, TextContent, Tool +from mcp_types import CallToolResult, TextContent, Tool from rich.text import Text if TYPE_CHECKING: @@ -29,6 +29,9 @@ TERMINAL_BYTES_PER_TOKEN, ) from fast_agent.event_progress import ProgressAction +from fast_agent.mcp.tool_result_metadata import ( + update_tool_result_display_metadata, +) from fast_agent.tools.execution_environment import ( ShellExecution, ShellExecutionRequest, @@ -119,7 +122,7 @@ def _default_minimal_process_profile() -> bool: def _text_result(message: str, *, is_error: bool) -> CallToolResult: return CallToolResult( - isError=is_error, + is_error=is_error, content=[TextContent(type="text", text=message)], ) @@ -758,10 +761,14 @@ def _finalize_shell_result_display( suppress_display = True if defer_display_to_tool_result and self._show_bash_output: suppress_display = False - result_meta = cast("Any", result) - result_meta._suppress_display = suppress_display - result_meta.exit_code = shell_result.exit_code - result_meta.output_line_count = output_state.output_line_count + update_tool_result_display_metadata( + result, + { + "suppress_display": suppress_display, + "exit_code": shell_result.exit_code, + "output_line_count": output_state.output_line_count, + }, + ) return result async def _execute_shell_command( @@ -1501,8 +1508,8 @@ async def _call_process_lifecycle_tool( action=ProgressAction.TOOL_PROGRESS, tool_use_id=tool_use_id, tool_name=name, - details=details or ("failed" if result.isError else "completed"), - tool_state="failed" if result.isError else "completed", + details=details or ("failed" if result.is_error else "completed"), + tool_state="failed" if result.is_error else "completed", tool_terminal=True, process_yield_reason=yield_reason, ) @@ -1709,7 +1716,7 @@ async def _execute_parsed( self._flush_live_display_tail(process.display_state) process.display_state.use_live_shell_display = False if defer_display_to_tool_result: - cast("Any", result)._suppress_display = False + update_tool_result_display_metadata(result, {"suppress_display": False}) else: self._display.show_managed_process_status( process_id=process.process_id, @@ -1746,7 +1753,7 @@ async def _execute_parsed( action=ProgressAction.TOOL_PROGRESS, tool_use_id=tool_use_id, details=completion_details, - tool_state="failed" if result.isError else "completed", + tool_state="failed" if result.is_error else "completed", tool_terminal=True, ) return result diff --git a/src/fast_agent/tools/shell_tool_definitions.py b/src/fast_agent/tools/shell_tool_definitions.py index 214a78601..cca5cf284 100644 --- a/src/fast_agent/tools/shell_tool_definitions.py +++ b/src/fast_agent/tools/shell_tool_definitions.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, Literal, cast -from mcp.types import Tool +from mcp_types import Tool from fast_agent.constants import MAX_TERMINAL_OUTPUT_BYTE_LIMIT from fast_agent.tools.filesystem_tool_args import ( @@ -84,7 +84,7 @@ def build_execute_tool(*, shell_name: str) -> Tool: "`cwd` and `output_byte_limit` apply only to this command. Pipelines " "report the final command's status unless you enable `pipefail`." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "command": { @@ -169,7 +169,7 @@ def build_poll_process_tool( "remains buffered until completion or the deadline. Repeated polls return " "only output not returned previously." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "process_id": { @@ -210,7 +210,7 @@ def build_terminate_process_tool() -> Tool: "Terminate a managed shell process and its process group. Returns success " "if the process was terminated or had already exited." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "process_id": { @@ -235,7 +235,7 @@ def build_minimal_bash_tool(*, shell_name: str) -> Tool: "to detach services. Foreground commands that take time may yield a " "managed process ID; use Process to inspect, wait for, or stop it." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "command": {"type": "string"}, @@ -270,7 +270,7 @@ def build_minimal_process_tool( f"Use {default_wait_seconds} seconds unless more frequent monitoring " "is needed. `stop` terminates the process group." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "process_id": { @@ -303,7 +303,7 @@ def set_poll_process_tool_default_wait_seconds( *, default_wait_seconds: int, ) -> None: - properties = tool.inputSchema.get("properties") + properties = tool.input_schema.get("properties") if not isinstance(properties, dict): return wait_schema = properties.get("wait_sec") diff --git a/src/fast_agent/tools/skill_reader.py b/src/fast_agent/tools/skill_reader.py index 9bd3b6190..fe11b6d94 100644 --- a/src/fast_agent/tools/skill_reader.py +++ b/src/fast_agent/tools/skill_reader.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from mcp.types import CallToolResult, TextContent, Tool +from mcp_types import CallToolResult, TextContent, Tool from fast_agent.tools.filesystem_tool_args import ( coerce_required_string_argument, @@ -57,7 +57,7 @@ def __init__( "Read a skill's SKILL.md file or associated resources. " "Use this to load skill instructions before using the skill." ), - inputSchema={ + input_schema={ "type": "object", "properties": { "path": { @@ -144,6 +144,6 @@ def _skill_path_error_message(self, path: Path) -> str | None: def _text_result(text: str, *, is_error: bool) -> CallToolResult: return CallToolResult( - isError=is_error, + is_error=is_error, content=[TextContent(type="text", text=text)], ) diff --git a/src/fast_agent/tools/tool_sources.py b/src/fast_agent/tools/tool_sources.py index e923fc9ce..baa2cd74b 100644 --- a/src/fast_agent/tools/tool_sources.py +++ b/src/fast_agent/tools/tool_sources.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Final, Literal if TYPE_CHECKING: - from mcp.types import Tool + from mcp_types import Tool FAST_AGENT_TOOL_SOURCE_META: Final = "fast-agent/toolSource" diff --git a/src/fast_agent/types/__init__.py b/src/fast_agent/types/__init__.py index a525ec7ec..06adc42dc 100644 --- a/src/fast_agent/types/__init__.py +++ b/src/fast_agent/types/__init__.py @@ -8,7 +8,7 @@ # Re-export common enums/types # Public request parameters used to configure LLM calls # Re-export ResourceLink from MCP for convenience -from mcp.types import ResourceLink +from mcp_types import ResourceLink from fast_agent.llm.request_params import ( RequestParams, diff --git a/src/fast_agent/types/agent_io.py b/src/fast_agent/types/agent_io.py index dfe48820a..2c05e52e3 100644 --- a/src/fast_agent/types/agent_io.py +++ b/src/fast_agent/types/agent_io.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.mcp.helpers.content_helpers import normalize_to_extended_list from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/src/fast_agent/ui/console_display.py b/src/fast_agent/ui/console_display.py index e603469f9..1643a9f85 100644 --- a/src/fast_agent/ui/console_display.py +++ b/src/fast_agent/ui/console_display.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable -from mcp.types import CallToolResult, ContentBlock +from mcp_types import CallToolResult, ContentBlock from rich.console import Group, RenderableType from rich.markdown import Markdown from rich.markup import escape as escape_markup diff --git a/src/fast_agent/ui/elicitation_form.py b/src/fast_agent/ui/elicitation_form.py index e4cade6c8..71b5c700a 100644 --- a/src/fast_agent/ui/elicitation_form.py +++ b/src/fast_agent/ui/elicitation_form.py @@ -5,7 +5,7 @@ from datetime import date, datetime from typing import Any -from mcp.types import ElicitRequestedSchema +from mcp_types import ElicitRequestedSchema from prompt_toolkit import Application from prompt_toolkit.buffer import Buffer from prompt_toolkit.filters import Condition diff --git a/src/fast_agent/ui/history_actions.py b/src/fast_agent/ui/history_actions.py index 52441710b..8244ec010 100644 --- a/src/fast_agent/ui/history_actions.py +++ b/src/fast_agent/ui/history_actions.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol, cast -from mcp.types import TextContent +from mcp_types import TextContent from rich.text import Text from fast_agent.constants import FAST_AGENT_TOOL_METADATA @@ -21,7 +21,7 @@ ) if TYPE_CHECKING: - from mcp.types import CallToolRequest + from mcp_types import CallToolRequest from fast_agent.config import Settings from fast_agent.types import PromptMessageExtended diff --git a/src/fast_agent/ui/history_display_rows.py b/src/fast_agent/ui/history_display_rows.py index 6659c5f70..fef48068b 100644 --- a/src/fast_agent/ui/history_display_rows.py +++ b/src/fast_agent/ui/history_display_rows.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence - from mcp.types import CallToolRequest, CallToolResult + from mcp_types import CallToolRequest, CallToolResult from fast_agent.types import PromptMessageExtended @@ -249,7 +249,7 @@ def _tool_result_rows( result_summary = _extract_tool_result_summary(result) total_chars += result_summary.chars has_non_text = has_non_text or result_summary.non_text - is_error = result.isError + is_error = result.is_error has_error = has_error or is_error tool_timing_info = tool_timings.get(call_id) last_timing_ms = tool_timing_info.get("timing_ms") if tool_timing_info else None diff --git a/src/fast_agent/ui/interactive_prompt.py b/src/fast_agent/ui/interactive_prompt.py index 139a35bac..bc498c562 100644 --- a/src/fast_agent/ui/interactive_prompt.py +++ b/src/fast_agent/ui/interactive_prompt.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable -from mcp.types import PromptMessage +from mcp_types import PromptMessage from rich import print as rich_print from rich.text import Text diff --git a/src/fast_agent/ui/mcp_display.py b/src/fast_agent/ui/mcp_display.py index c8d04a495..2ec94b3f5 100644 --- a/src/fast_agent/ui/mcp_display.py +++ b/src/fast_agent/ui/mcp_display.py @@ -281,12 +281,12 @@ def _format_capability_shorthand( experimental_caps = caps.experimental if caps else None entries: list[tuple[str, CapabilityState, bool]] = [ - ("To", bool(tools), bool(tools and tools.listChanged)), - ("Pr", bool(prompts), bool(prompts and prompts.listChanged)), + ("To", bool(tools), bool(tools and tools.list_changed)), + ("Pr", bool(prompts), bool(prompts and prompts.list_changed)), ( "Re", bool(resources), - bool(resources and resources.listChanged), + bool(resources and resources.list_changed), ), ("Rs", bool(resources and resources.subscribe), bool(resources and resources.subscribe)), ("Lo", bool(logging_caps), False), @@ -1007,7 +1007,20 @@ def _render_server_metadata(status: ServerStatus, *, indent: str) -> None: console.console.print(meta_line) session_line = Text(indent + " ") - session_line.append_text(_build_aligned_field("session", _format_session_id(status.session_id))) + protocol = status.protocol_version or "unknown" + if status.protocol_era: + protocol += f" ({status.protocol_era}" + if status.negotiation: + protocol += f", {status.negotiation}" + protocol += ")" + session_line.append_text(_build_aligned_field("protocol", protocol)) + session_line.append(" ", style="dim") + if status.protocol_era == "modern": + session_line.append_text(_build_aligned_field("session", "none (sessionless)")) + else: + session_line.append_text( + _build_aligned_field("session", _format_session_id(status.session_id)) + ) console.console.print(session_line) health_text = _build_health_text(status) diff --git a/src/fast_agent/ui/mcp_ui_utils.py b/src/fast_agent/ui/mcp_ui_utils.py index 1799bdaf8..9e72efbde 100644 --- a/src/fast_agent/ui/mcp_ui_utils.py +++ b/src/fast_agent/ui/mcp_ui_utils.py @@ -10,8 +10,9 @@ from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING +from urllib.parse import quote -from mcp.types import BlobResourceContents, ContentBlock, EmbeddedResource, TextResourceContents +from mcp_types import BlobResourceContents, ContentBlock, EmbeddedResource, TextResourceContents from fast_agent.paths import resolve_mcp_ui_output_dir from fast_agent.utils.filename import sanitize_filename_component @@ -85,11 +86,11 @@ def _decode_text_or_blob(resource: UIResourceContents) -> str | None: def _resource_uri(resource: UIResourceContents) -> str | None: - return str(resource.uri) if resource.uri else None + return quote(resource.uri, safe=":/?#[]@!$&'()*+,;=%") if resource.uri else None def _resource_mime_type(resource: UIResourceContents) -> str: - return resource.mimeType or "" + return resource.mime_type or "" def _first_https_url_from_uri_list(text: str) -> str | None: diff --git a/src/fast_agent/ui/message_display_helpers.py b/src/fast_agent/ui/message_display_helpers.py index 34e7d5f05..79d4b678d 100644 --- a/src/fast_agent/ui/message_display_helpers.py +++ b/src/fast_agent/ui/message_display_helpers.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence - from mcp.types import CallToolRequest, ContentBlock + from mcp_types import CallToolRequest, ContentBlock from fast_agent.types import PromptMessageExtended from fast_agent.ui.terminal_images import ImageRenderItem @@ -52,17 +52,17 @@ def extract_user_attachments(message: PromptMessageExtended) -> list[str]: for content in message.content: if is_resource_link(content): # ResourceLink: show name or mime type - from mcp.types import ResourceLink + from mcp_types import ResourceLink assert isinstance(content, ResourceLink) - label = content.name or content.mimeType or "resource" + label = content.name or content.mime_type or "resource" attachments.append(label) elif is_image_content(content): source_uri = _content_source_uri(content) attachments.append(f"image ({source_uri})" if source_uri else "image") elif is_resource_content(content): # EmbeddedResource: show name or uri - from mcp.types import EmbeddedResource + from mcp_types import EmbeddedResource assert isinstance(content, EmbeddedResource) label = getattr(content.resource, "name", None) or str(content.resource.uri) @@ -106,7 +106,7 @@ def _is_local_image_content(content: "ContentBlock") -> bool: def _message_display_text(message: PromptMessageExtended) -> str: - from mcp.types import TextContent + from mcp_types import TextContent for content in message.content: if not isinstance(content, TextContent): diff --git a/src/fast_agent/ui/prompt/completer.py b/src/fast_agent/ui/prompt/completer.py index 269e65b99..baf84d1ae 100644 --- a/src/fast_agent/ui/prompt/completer.py +++ b/src/fast_agent/ui/prompt/completer.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, cast, runtime_checkable from urllib.parse import unquote -from mcp.types import ResourceTemplate +from mcp_types import ResourceTemplate from prompt_toolkit.completion import Completer, Completion from fast_agent.agents.agent_types import AgentType @@ -64,7 +64,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Coroutine, Iterable, Iterator, Sequence - from mcp.types import ListToolsResult + from mcp_types import ListToolsResult from fast_agent.core.agent_app import AgentApp from fast_agent.interfaces import FastAgentLLMProtocol @@ -1941,13 +1941,13 @@ def _mention_remote_resource_completions( ] completions.extend( Completion( - f"{template.uriTemplate}{{", + f"{template.uri_template}{{", start_position=-len(context.partial), - display=template.uriTemplate, + display=template.uri_template, display_meta="resource template", ) for template in templates - if not context.partial or starts_with_casefold(template.uriTemplate, context.partial) + if not context.partial or starts_with_casefold(template.uri_template, context.partial) ) self._completion_cache_put(cache_key, completions) diff --git a/src/fast_agent/ui/prompt/resource_mentions.py b/src/fast_agent/ui/prompt/resource_mentions.py index b0a0bcab8..3d6b2ef08 100644 --- a/src/fast_agent/ui/prompt/resource_mentions.py +++ b/src/fast_agent/ui/prompt/resource_mentions.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable -from mcp.types import ContentBlock, EmbeddedResource, ReadResourceResult, TextContent +from mcp_types import ContentBlock, EmbeddedResource, ReadResourceResult, TextContent from uritemplate import URITemplate from fast_agent.mcp.helpers.content_helpers import image_link, resource_link @@ -319,7 +319,7 @@ def _resolve_local_content_block(path_text: str) -> ContentBlock: def _resolve_remote_content_block(url: str) -> ContentBlock: inferred = resource_link(url) - mime_type = inferred.mimeType or "application/octet-stream" + mime_type = inferred.mime_type or "application/octet-stream" if mime_type.startswith("image/"): return image_link(url, mime_type=mime_type) return inferred diff --git a/src/fast_agent/ui/prompt/status_bar/alert_flags.py b/src/fast_agent/ui/prompt/status_bar/alert_flags.py index c471d7065..12df28f55 100644 --- a/src/fast_agent/ui/prompt/status_bar/alert_flags.py +++ b/src/fast_agent/ui/prompt/status_bar/alert_flags.py @@ -5,7 +5,7 @@ import json from typing import TYPE_CHECKING -from mcp.types import ContentBlock, TextContent +from mcp_types import ContentBlock, TextContent from fast_agent.constants import ( FAST_AGENT_ALERT_CHANNEL, diff --git a/src/fast_agent/ui/prompt/status_bar/attachment.py b/src/fast_agent/ui/prompt/status_bar/attachment.py index 7816569e2..2d89d5089 100644 --- a/src/fast_agent/ui/prompt/status_bar/attachment.py +++ b/src/fast_agent/ui/prompt/status_bar/attachment.py @@ -47,7 +47,7 @@ def _attachment_resource( url_mention_server: str, ) -> _AttachmentResource: if server_name == url_mention_server: - mime_type = resource_link(resource_uri).mimeType or UNKNOWN_ATTACHMENT_MIME + mime_type = resource_link(resource_uri).mime_type or UNKNOWN_ATTACHMENT_MIME return _AttachmentResource(mime_type=mime_type, source="link") return _AttachmentResource( mime_type=_local_attachment_mime_type(Path(resource_uri)), diff --git a/src/fast_agent/ui/terminal_images/renderer.py b/src/fast_agent/ui/terminal_images/renderer.py index 15788d29e..9733b8262 100644 --- a/src/fast_agent/ui/terminal_images/renderer.py +++ b/src/fast_agent/ui/terminal_images/renderer.py @@ -11,7 +11,7 @@ from urllib.parse import unquote, urlparse from urllib.request import Request, urlopen -from mcp.types import BlobResourceContents, EmbeddedResource, ImageContent, TextContent +from mcp_types import BlobResourceContents, EmbeddedResource, ImageContent, TextContent from rich.console import Group, RenderableType from rich.text import Text @@ -196,12 +196,12 @@ def _artifact_from_content(item: object, index: int) -> ImageArtifact | None: return None return ImageArtifact( data=data, - mime_type=item.mimeType, - label=_label(index, item.mimeType, len(data), None), + mime_type=item.mime_type, + label=_label(index, item.mime_type, len(data), None), ) if isinstance(item, EmbeddedResource) and isinstance(item.resource, BlobResourceContents): - mime_type = item.resource.mimeType or "application/octet-stream" + mime_type = item.resource.mime_type or "application/octet-stream" if not mime_type.startswith("image/"): return None data = _decode_base64(item.resource.blob) diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index ddfe58970..ad57af2b2 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -13,7 +13,10 @@ from fast_agent.config import Settings, ShellSettings from fast_agent.constants import FAST_AGENT_SHELL_PROCESS_METADATA from fast_agent.core.logging.logger import get_logger -from fast_agent.mcp.tool_result_metadata import get_tool_result_media_preview +from fast_agent.mcp.tool_result_metadata import ( + get_tool_result_media_preview, + tool_result_display_metadata, +) from fast_agent.tools.apply_patch_tool import extract_apply_patch_input, is_apply_patch_tool_name from fast_agent.ui import console from fast_agent.ui.apply_patch_preview import ( @@ -46,7 +49,7 @@ if TYPE_CHECKING: from collections.abc import Mapping - from mcp.types import CallToolResult + from mcp_types import CallToolResult from rich.console import RenderableType from fast_agent.mcp.skybridge import SkybridgeServerConfig @@ -342,7 +345,7 @@ def _build_shell_exit_additional_message( tool_call_id: str | None, output_line_count: int | None = None, ): - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content @@ -405,7 +408,7 @@ def _limit_shell_output_text(self, text: str, line_limit: int) -> str: return "\n".join(output_lines) def _limit_shell_output_content(self, content, line_limit: int): - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content @@ -444,7 +447,7 @@ def _limit_read_text_output_text(self, text: str, line_limit: int) -> LimitedRea ) def _limit_read_text_output_content(self, content, line_limit: int): - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content @@ -625,7 +628,7 @@ def _prepare_tool_result_content( @staticmethod def _compact_managed_process_result(content, *, tool_name: str | None): """Hide model-oriented process metadata in favor of one lifecycle line.""" - from mcp.types import TextContent + from mcp_types import TextContent if normalize_tool_name(tool_name) not in SHELL_EXECUTION_TOOL_NAMES: return content @@ -703,7 +706,7 @@ def _is_quiet_running_process_result( tool_name: str | None, ) -> bool: """Return whether a poll result contains liveness metadata but no new output.""" - from mcp.types import TextContent + from mcp_types import TextContent if normalize_tool_name(tool_name) != POLL_PROCESS_TOOL_NAME: if normalize_tool_name(tool_name) != normalize_tool_name(PROCESS_TOOL_NAME): @@ -729,7 +732,7 @@ def _structured_tool_result_display_content( content, structured_content: object = None, ): - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.mcp.helpers.content_helpers import is_text_content @@ -775,9 +778,9 @@ def _default_tool_result_status(self, result: "CallToolResult") -> str: content = self._structured_tool_result_display_content( content=result.content, - structured_content=getattr(result, "structuredContent", None), + structured_content=getattr(result, "structured_content", None), ) - if result.isError: + if result.is_error: return "ERROR" if not content: @@ -795,20 +798,20 @@ def _default_tool_result_status(self, result: "CallToolResult") -> str: return format_count(len(content), "Content Block", "Content Blocks") @staticmethod - def _optional_string_attribute(result: "CallToolResult", name: str) -> str | None: - value = getattr(result, name, None) + def _optional_string_metadata(metadata: dict[str, object], name: str) -> str | None: + value = metadata.get(name) return strip_str_to_none(value) @staticmethod - def _optional_int_attribute(result: "CallToolResult", name: str) -> int | None: - return positive_int_or_none(getattr(result, name, None)) + def _optional_int_metadata(metadata: dict[str, object], name: str) -> int | None: + return positive_int_or_none(metadata.get(name)) @staticmethod def _optional_nonnegative_int_attribute( - result: "CallToolResult", + metadata: dict[str, object], name: str, ) -> int | None: - value = getattr(result, name, None) + value = metadata.get(name) if type(value) is not int or value < 0: return None return value @@ -818,13 +821,14 @@ def _tool_result_display_metadata( cls, result: "CallToolResult", ) -> ToolResultDisplayMetadata: + metadata = dict(tool_result_display_metadata(result)) return ToolResultDisplayMetadata( - read_text_file_path=cls._optional_string_attribute(result, "read_text_file_path"), - read_text_file_line=cls._optional_int_attribute(result, "read_text_file_line"), - read_text_file_limit=cls._optional_int_attribute(result, "read_text_file_limit"), - transport_channel=cls._optional_string_attribute(result, "transport_channel"), + read_text_file_path=cls._optional_string_metadata(metadata, "read_text_file_path"), + read_text_file_line=cls._optional_int_metadata(metadata, "read_text_file_line"), + read_text_file_limit=cls._optional_int_metadata(metadata, "read_text_file_limit"), + transport_channel=cls._optional_string_metadata(metadata, "transport_channel"), output_line_count=cls._optional_nonnegative_int_attribute( - result, + metadata, "output_line_count", ), ) @@ -836,7 +840,7 @@ def _tool_result_status( tool_name: str | None, metadata: ToolResultDisplayMetadata, ) -> str: - if is_read_text_file_tool_name(tool_name) and not result.isError: + if is_read_text_file_tool_name(tool_name) and not result.is_error: return self._read_text_file_header_status( metadata.read_text_file_path, line_value=metadata.read_text_file_line, @@ -878,7 +882,7 @@ def _build_tool_result_bottom_metadata( def _has_structured_text_content_mismatch(result: "CallToolResult") -> bool: from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content - structured_content = getattr(result, "structuredContent", None) + structured_content = getattr(result, "structured_content", None) content = getattr(result, "content", None) if not ( isinstance(structured_content, (dict, list)) @@ -916,7 +920,7 @@ def _prepare_read_text_file_result_display( display_content, read_omitted_line_count: int, ) -> PreparedReadTextFileResultDisplay: - if not is_read_text_file_tool_name(tool_name) or result.isError: + if not is_read_text_file_tool_name(tool_name) or result.is_error: return PreparedReadTextFileResultDisplay(display_content=display_content) render_markdown: bool | None = None @@ -1005,7 +1009,7 @@ def _render_structured_tool_result( post_content: RenderableType | None = None, ) -> None: config_map = MESSAGE_CONFIGS[MessageType.TOOL_RESULT] - block_color = "red" if result.isError else config_map["block_color"] + block_color = "red" if result.is_error else config_map["block_color"] arrow = config_map["arrow"] arrow_style = config_map["arrow_style"] @@ -1014,7 +1018,7 @@ def _render_structured_tool_result( arrow=arrow, arrow_style=arrow_style, name=name, - is_error=result.isError, + is_error=result.is_error, show_hook_indicator=show_hook_indicator, ) @@ -1022,7 +1026,7 @@ def _render_structured_tool_result( self._display._display_content( display_content, truncate_content, - result.isError, + result.is_error, MessageType.TOOL_RESULT, check_markdown_markers=False, ) @@ -1065,7 +1069,7 @@ def show_tool_result( try: metadata = self._tool_result_display_metadata(result) - structured_content = getattr(result, "structuredContent", None) + structured_content = getattr(result, "structured_content", None) has_structured = structured_content is not None prepared_content = self._prepare_tool_result_content( content=result.content, @@ -1151,7 +1155,7 @@ def show_tool_result( name=name, right_info=right_info, bottom_metadata=bottom_metadata, - is_error=result.isError, + is_error=result.is_error, truncate_content=truncate_content, additional_message=additional_message, post_content=post_content, @@ -1163,7 +1167,7 @@ def show_tool_result( "Tool result display failed", tool_name=tool_name, agent_name=name, - is_error=result.isError, + is_error=result.is_error, ) def _shell_tool_call_content( diff --git a/src/fast_agent/workflow_telemetry.py b/src/fast_agent/workflow_telemetry.py index de2f1efa8..7e8c84751 100644 --- a/src/fast_agent/workflow_telemetry.py +++ b/src/fast_agent/workflow_telemetry.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, Protocol -from mcp.types import ContentBlock, TextContent +from mcp_types import ContentBlock, TextContent if TYPE_CHECKING: from fast_agent.mcp.tool_execution_handler import ToolExecutionHandler diff --git a/tests/e2e/history/test_history_save_load_e2e.py b/tests/e2e/history/test_history_save_load_e2e.py index fa7ae7eac..e40e8646b 100644 --- a/tests/e2e/history/test_history_save_load_e2e.py +++ b/tests/e2e/history/test_history_save_load_e2e.py @@ -4,7 +4,7 @@ from typing import AsyncIterator import pytest -from mcp.types import CallToolResult, TextContent, Tool +from mcp_types import CallToolResult, TextContent, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent @@ -30,7 +30,7 @@ MAGIC_TOOL = Tool( name="fetch_magic_string", description="Returns the daily passphrase when the assistant must call a tool.", - inputSchema={ + input_schema={ "type": "object", "properties": { "purpose": { @@ -86,7 +86,7 @@ async def _create_history(agent: LlmAgent) -> None: tool_call = await agent.generate( request, tools=[MAGIC_TOOL], - request_params=RequestParams(maxTokens=300), + request_params=RequestParams(max_tokens=300), ) assert tool_call.stop_reason is LlmStopReason.TOOL_USE assert tool_call.tool_calls diff --git a/tests/e2e/llm/test_llm_e2e.py b/tests/e2e/llm/test_llm_e2e.py index 0af779638..1de725f0e 100644 --- a/tests/e2e/llm/test_llm_e2e.py +++ b/tests/e2e/llm/test_llm_e2e.py @@ -5,7 +5,7 @@ import pytest import pytest_asyncio -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolRequest, CallToolRequestParams, @@ -15,7 +15,7 @@ TextContent, Tool, ) -from pydantic import AnyUrl, BaseModel, Field +from pydantic import BaseModel, Field from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent @@ -102,7 +102,7 @@ async def llm_agent_setup(model_name): _tool = Tool( name="weather", description="call this to check the weather in a city", - inputSchema=_input_schema, + input_schema=_input_schema, ) _const_input_schema = { @@ -119,7 +119,7 @@ async def llm_agent_setup(model_name): _const_tool = Tool( name="const_mode", description="Demonstrates a tool schema that includes a const constraint.", - inputSchema=_const_input_schema, + input_schema=_const_input_schema, ) @@ -141,7 +141,7 @@ async def test_max_tokens_limit(llm_agent_setup, model_name): """Test generation with max tokens limit returns MAX_TOKENS stop reason.""" agent = llm_agent_setup result: PromptMessageExtended = await agent.generate( - "write a 300 word story", RequestParams(maxTokens=15) + "write a 300 word story", RequestParams(max_tokens=15) ) assert result.stop_reason is LlmStopReason.MAX_TOKENS @@ -153,7 +153,7 @@ async def test_stop_sequence(llm_agent_setup, model_name): """Test generation with stop sequence returns STOP_SEQUENCE stop reason.""" agent = llm_agent_setup result: PromptMessageExtended = await agent.generate( - "repeat after me, `one, two, three`.", RequestParams(stopSequences=[" two,"]) + "repeat after me, `one, two, three`.", RequestParams(stop_sequences=[" two,"]) ) # oai reasoning models don't support this # we will also need to remove this for multimodal messages with oai @@ -214,7 +214,7 @@ async def test_tool_user_continuation(llm_agent_setup, model_name): result = await agent.generate( "check the weather in new york", tools=[_tool], - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ) assert LlmStopReason.TOOL_USE is result.stop_reason assert result.tool_calls @@ -240,7 +240,7 @@ async def test_tool_const_schema(llm_agent_setup, model_name): result = await agent.generate( "call the const_mode tool so I can confirm the mode you must use.", tools=[_const_tool], - request_params=RequestParams(maxTokens=max_tokens), + request_params=RequestParams(max_tokens=max_tokens), ) assert result.stop_reason is LlmStopReason.TOOL_USE @@ -260,7 +260,7 @@ async def test_tool_calling_agent(llm_agent_setup, model_name): result = await agent.generate( "check the weather in new york", tools=[_tool], - request_params=RequestParams(maxTokens=300), + request_params=RequestParams(max_tokens=300), ) assert LlmStopReason.TOOL_USE is result.stop_reason assert result.tool_calls @@ -365,7 +365,7 @@ async def test_mcp_tool_result_image_reads_name(llm_agent_setup, model_name): tool_result = CallToolResult( content=[ TextContent(type="text", text="Here's your image:"), - ImageContent(type="image", data=image_b64, mimeType="image/png"), + ImageContent(type="image", data=image_b64, mime_type="image/png"), ] ) @@ -419,7 +419,7 @@ async def test_mcp_tool_result_pdf_summarizes_name(llm_agent_setup, model_name): embedded_pdf = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl(f"file://{pdf_path}"), blob=pdf_b64, mimeType="application/pdf" + uri=f"file://{pdf_path}", blob=pdf_b64, mime_type="application/pdf" ), ) tool_result = CallToolResult( diff --git a/tests/e2e/llm/test_responses_e2e.py b/tests/e2e/llm/test_responses_e2e.py index f847a0cdd..f16defec2 100644 --- a/tests/e2e/llm/test_responses_e2e.py +++ b/tests/e2e/llm/test_responses_e2e.py @@ -4,7 +4,7 @@ import pytest import pytest_asyncio -from mcp.types import CallToolRequest, CallToolResult, Tool +from mcp_types import CallToolRequest, CallToolResult, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent @@ -42,7 +42,7 @@ def get_responses_models() -> list[str]: _tool = Tool( name="weather", description="Check the weather in a city", - inputSchema=_tool_schema, + input_schema=_tool_schema, ) @@ -70,7 +70,7 @@ async def test_responses_streaming_summary(responses_agent, model_name): try: result: "PromptMessageExtended" = await agent.generate( "In one sentence, what is the capital of France?", - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ) finally: remove_listener() @@ -111,7 +111,7 @@ def on_tool_event(event_type: str, payload: dict | None = None) -> None: result: "PromptMessageExtended" = await agent.generate( "Call the weather tool for Paris.", tools=[_tool], - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ) finally: remove_listener() @@ -148,7 +148,7 @@ async def test_responses_tool_followup(responses_agent, model_name): first_response: "PromptMessageExtended" = await agent.generate( tool_prompt, tools=[_tool], - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ) assert first_response.stop_reason is LlmStopReason.TOOL_USE assert first_response.tool_calls @@ -162,7 +162,7 @@ async def test_responses_tool_followup(responses_agent, model_name): pytest.skip("Model did not return encrypted reasoning to pass back.") tool_use_id = next(iter(first_response.tool_calls.keys())) - tool_result = CallToolResult(content=[text_content("It is sunny.")], isError=False) + tool_result = CallToolResult(content=[text_content("It is sunny.")], is_error=False) tool_message = PromptMessageExtended( role="user", content=[], @@ -172,7 +172,7 @@ async def test_responses_tool_followup(responses_agent, model_name): followup_prompt = "Use the tool result to answer in one sentence and do not call tools." followup_response: "PromptMessageExtended" = await agent.generate( [tool_prompt, first_response, tool_message, followup_prompt], - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ) assert followup_response.stop_reason is LlmStopReason.END_TURN diff --git a/tests/e2e/llm/test_responses_websocket_e2e.py b/tests/e2e/llm/test_responses_websocket_e2e.py index 3cd930b4e..31ee93f65 100644 --- a/tests/e2e/llm/test_responses_websocket_e2e.py +++ b/tests/e2e/llm/test_responses_websocket_e2e.py @@ -80,7 +80,7 @@ async def websocket_agent(model_name: str) -> LlmAgent: async def _generate_and_assert_websocket(agent: LlmAgent, prompt: str) -> None: result = await agent.generate( prompt, - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ) assert result.stop_reason is LlmStopReason.END_TURN assert result.last_text() diff --git a/tests/e2e/llm/test_responses_websocket_local_e2e.py b/tests/e2e/llm/test_responses_websocket_local_e2e.py index 2da8ced9d..11eb5b8ef 100644 --- a/tests/e2e/llm/test_responses_websocket_local_e2e.py +++ b/tests/e2e/llm/test_responses_websocket_local_e2e.py @@ -7,7 +7,7 @@ import pytest import pytest_asyncio from aiohttp import WSMsgType, web -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.llm.provider.openai.codex_responses import CodexResponsesLLM from fast_agent.llm.provider.openai.responses import RESPONSES_DIAGNOSTICS_CHANNEL diff --git a/tests/e2e/multimodal/image_server.py b/tests/e2e/multimodal/image_server.py index 77b880ce1..06d2bcfea 100644 --- a/tests/e2e/multimodal/image_server.py +++ b/tests/e2e/multimodal/image_server.py @@ -7,11 +7,10 @@ import logging import sys from pathlib import Path -from typing import cast from fastmcp import FastMCP from fastmcp.utilities.types import Image -from mcp.types import AnyUrl, BlobResourceContents, EmbeddedResource, ImageContent, TextContent +from mcp_types import BlobResourceContents, EmbeddedResource, ImageContent, TextContent # Configure logging logging.basicConfig(level=logging.INFO) @@ -66,9 +65,9 @@ async def get_pdf() -> list[TextContent | EmbeddedResource]: EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=cast("AnyUrl", f"file://{Path(pdf_path).absolute()}"), + uri=f"file://{Path(pdf_path).absolute()}", blob=b64_data, - mimeType="application/pdf", + mime_type="application/pdf", ), ), ] diff --git a/tests/e2e/multimodal/mixed_content_server.py b/tests/e2e/multimodal/mixed_content_server.py index fc6a6848b..984bf4d46 100644 --- a/tests/e2e/multimodal/mixed_content_server.py +++ b/tests/e2e/multimodal/mixed_content_server.py @@ -14,7 +14,7 @@ import logging from fastmcp import FastMCP -from mcp.types import ImageContent, TextContent +from mcp_types import ImageContent, TextContent # Configure logging logging.basicConfig(level=logging.INFO) @@ -56,7 +56,7 @@ def take_screenshot() -> list[TextContent | ImageContent]: return [ TextContent(type="text", text="Screenshot captured successfully"), - ImageContent(type="image", data=fake_image_data, mimeType="image/png"), + ImageContent(type="image", data=fake_image_data, mime_type="image/png"), ] except Exception as e: logger.exception(f"Error creating screenshot: {e}") @@ -83,7 +83,7 @@ def get_both_data() -> list[TextContent | ImageContent]: return [ TextContent(type="text", text="Combined data: Page loaded, screenshot taken"), TextContent(type="text", text="Navigation state: Ready"), - ImageContent(type="image", data=fake_image_data, mimeType="image/png"), + ImageContent(type="image", data=fake_image_data, mime_type="image/png"), ] except Exception as e: logger.exception(f"Error getting combined data: {e}") diff --git a/tests/e2e/multimodal/test_gemini_video.py b/tests/e2e/multimodal/test_gemini_video.py index bd165dbdf..eaba7db3e 100644 --- a/tests/e2e/multimodal/test_gemini_video.py +++ b/tests/e2e/multimodal/test_gemini_video.py @@ -2,8 +2,7 @@ from pathlib import Path import pytest -from mcp.types import BlobResourceContents, EmbeddedResource -from pydantic import AnyUrl +from mcp_types import BlobResourceContents, EmbeddedResource from fast_agent.types import PromptMessageExtended, video_link @@ -108,8 +107,8 @@ async def test_gemini_video_local_content(fast_agent, model_name): video_resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl(video_path.resolve().as_uri()), - mimeType="video/mp4", + uri=video_path.resolve().as_uri(), + mime_type="video/mp4", blob=video_b64, ), ) diff --git a/tests/e2e/multimodal/test_multimodal_images.py b/tests/e2e/multimodal/test_multimodal_images.py index 35391ad01..30a0c65aa 100644 --- a/tests/e2e/multimodal/test_multimodal_images.py +++ b/tests/e2e/multimodal/test_multimodal_images.py @@ -257,7 +257,7 @@ async def agent_function(): ) ] ) - from mcp.types import TextContent + from mcp_types import TextContent def is_thought_part(part_content): # Check if it's a TextContent and if its text starts with "thought" (case-insensitive) diff --git a/tests/e2e/multimodal/test_openai_tool_validation_fix.py b/tests/e2e/multimodal/test_openai_tool_validation_fix.py index 9c5c2549f..351168d21 100644 --- a/tests/e2e/multimodal/test_openai_tool_validation_fix.py +++ b/tests/e2e/multimodal/test_openai_tool_validation_fix.py @@ -14,7 +14,7 @@ """ import pytest -from mcp.types import CallToolResult, ImageContent, TextContent +from mcp_types import CallToolResult, ImageContent, TextContent def _require_tool_result(value: CallToolResult | BaseException, label: str) -> CallToolResult: diff --git a/tests/e2e/multimodal/video_server.py b/tests/e2e/multimodal/video_server.py index 3623a1f00..506d0d891 100644 --- a/tests/e2e/multimodal/video_server.py +++ b/tests/e2e/multimodal/video_server.py @@ -7,7 +7,7 @@ import sys from fastmcp import FastMCP -from mcp.types import ResourceLink, TextContent +from mcp_types import ResourceLink, TextContent from fast_agent.mcp.helpers.content_helpers import text_content, video_link diff --git a/tests/e2e/sampling/sampling_resource_server.py b/tests/e2e/sampling/sampling_resource_server.py index de381af72..8e7987b1a 100644 --- a/tests/e2e/sampling/sampling_resource_server.py +++ b/tests/e2e/sampling/sampling_resource_server.py @@ -1,7 +1,7 @@ from fastmcp import Context, FastMCP from fastmcp.server.dependencies import get_context from fastmcp.utilities.types import Image -from mcp.types import SamplingMessage, TextContent +from mcp_types import SamplingMessage, TextContent from fast_agent.mcp.helpers.content_helpers import get_text diff --git a/tests/integration/a2a/test_fast_agent_a2a_server.py b/tests/integration/a2a/test_fast_agent_a2a_server.py index 01776b905..e072d7d53 100644 --- a/tests/integration/a2a/test_fast_agent_a2a_server.py +++ b/tests/integration/a2a/test_fast_agent_a2a_server.py @@ -29,14 +29,13 @@ from fastapi.testclient import TestClient from fastmcp.server.auth import AccessToken from google.protobuf.json_format import MessageToDict -from mcp.types import ( +from mcp_types import ( BlobResourceContents, EmbeddedResource, ImageContent, TextContent, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.a2a.config import A2AAgentConfig from fast_agent.a2a.remote_agent import A2ARemoteAgent @@ -1240,7 +1239,7 @@ def test_fast_agent_a2a_server_preserves_raw_file_input_parts() -> None: assert isinstance(content, EmbeddedResource) assert isinstance(content.resource, BlobResourceContents) assert str(content.resource.uri) == "attachment:///report.pdf" - assert content.resource.mimeType == "application/pdf" + assert content.resource.mime_type == "application/pdf" assert content.resource.blob == "JVBERiB0ZXN0IGJ5dGVz" @@ -1263,7 +1262,7 @@ def test_fast_agent_a2a_server_maps_raw_image_input_parts() -> None: assert len(prompt.content) == 1 content = prompt.content[0] assert isinstance(content, ImageContent) - assert content.mimeType == "image/png" + assert content.mime_type == "image/png" assert content.data == "aW1hZ2UgYnl0ZXM=" @@ -1288,7 +1287,7 @@ def test_fast_agent_a2a_server_preserves_raw_audio_as_blob_resource() -> None: assert isinstance(content, EmbeddedResource) assert isinstance(content.resource, BlobResourceContents) assert str(content.resource.uri) == "attachment:///clip.wav" - assert content.resource.mimeType == "audio/wav" + assert content.resource.mime_type == "audio/wav" assert content.resource.blob == "YXVkaW8gYnl0ZXM=" @@ -1301,8 +1300,8 @@ def test_fast_agent_a2a_server_emits_blob_resources_as_raw_file_parts() -> None: EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("attachment:///report.pdf"), - mimeType="application/pdf", + uri="attachment:///report.pdf", + mime_type="application/pdf", blob="JVBERiB0ZXN0IGJ5dGVz", ), ) @@ -1325,8 +1324,8 @@ def test_fast_agent_a2a_server_emits_json_text_resources_as_data_parts() -> None EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource:///tickets.json"), - mimeType="application/json", + uri="resource:///tickets.json", + mime_type="application/json", text='{"tickets": [{"id": "REQ123", "status": "open"}]}', ), ) diff --git a/tests/integration/a2a/test_remote_agent_runtime.py b/tests/integration/a2a/test_remote_agent_runtime.py index 1d5822592..29d52f766 100644 --- a/tests/integration/a2a/test_remote_agent_runtime.py +++ b/tests/integration/a2a/test_remote_agent_runtime.py @@ -1,7 +1,7 @@ from __future__ import annotations import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.a2a.config import A2AAgentConfig from fast_agent.a2a.remote_agent import A2ARemoteAgent @@ -301,8 +301,7 @@ async def test_a2a_remote_agent_clone_preserves_remote_config(a2a_test_server) - @pytest.mark.integration @pytest.mark.asyncio async def test_a2a_remote_agent_sends_url_and_raw_parts(a2a_test_server) -> None: - from mcp.types import ImageContent, ResourceLink - from pydantic import AnyUrl + from mcp_types import ImageContent, ResourceLink agent = A2ARemoteAgent( config=AgentConfig(name="remote_attachments", agent_type=AgentType.A2A, use_history=False), @@ -319,10 +318,10 @@ async def test_a2a_remote_agent_sends_url_and_raw_parts(a2a_test_server) -> None ResourceLink( type="resource_link", name="report.pdf", - uri=AnyUrl("https://example.com/report.pdf"), - mimeType="application/pdf", + uri="https://example.com/report.pdf", + mime_type="application/pdf", ), - ImageContent(type="image", data="YWJj", mimeType="image/png"), + ImageContent(type="image", data="YWJj", mime_type="image/png"), ], ) ] diff --git a/tests/integration/acp/test_acp_sessions.py b/tests/integration/acp/test_acp_sessions.py index 79df35cbd..3e1cd9f10 100644 --- a/tests/integration/acp/test_acp_sessions.py +++ b/tests/integration/acp/test_acp_sessions.py @@ -12,7 +12,7 @@ from acp.helpers import text_block from acp.schema import ClientCapabilities, FileSystemCapabilities, Implementation from acp.stdio import spawn_agent_process -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/integration/acp/test_acp_slash_commands.py b/tests/integration/acp/test_acp_slash_commands.py index 6d18c8dd9..b34c3b540 100644 --- a/tests/integration/acp/test_acp_slash_commands.py +++ b/tests/integration/acp/test_acp_slash_commands.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from fast_agent.acp import ACPCommand from fast_agent.acp.slash_commands import SlashCommandHandler @@ -979,7 +979,7 @@ async def test_slash_command_history_show_turn_summary() -> None: tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="result")], - isError=False, + is_error=False, ) }, channels={ diff --git a/tests/integration/agent_hooks/test_agent_lifecycle_hooks.py b/tests/integration/agent_hooks/test_agent_lifecycle_hooks.py index e9e21686b..aed7db495 100644 --- a/tests/integration/agent_hooks/test_agent_lifecycle_hooks.py +++ b/tests/integration/agent_hooks/test_agent_lifecycle_hooks.py @@ -8,7 +8,7 @@ import pytest from mcp import CallToolRequest, Tool -from mcp.types import CallToolRequestParams +from mcp_types import CallToolRequestParams from fast_agent.llm.internal.passthrough import PassthroughLLM from fast_agent.mcp.prompt import Prompt diff --git a/tests/integration/api/mcp_dynamic_tools.py b/tests/integration/api/mcp_dynamic_tools.py index 88242c298..2a23d944c 100644 --- a/tests/integration/api/mcp_dynamic_tools.py +++ b/tests/integration/api/mcp_dynamic_tools.py @@ -25,7 +25,7 @@ async def check_weather(location: str) -> str: # Toggle the dynamic tool if dynamic_tool_registered: - app.remove_tool("dynamic_tool") + app.local_provider.remove_tool("dynamic_tool") dynamic_tool_registered = False else: app.add_tool( diff --git a/tests/integration/api/mcp_progress_server.py b/tests/integration/api/mcp_progress_server.py index f23943803..78e01fdb7 100644 --- a/tests/integration/api/mcp_progress_server.py +++ b/tests/integration/api/mcp_progress_server.py @@ -25,7 +25,7 @@ async def progress_task(steps: int = 5) -> str: assert request_context is not None # Get the progress token from the request metadata - progress_token = request_context.meta.progressToken if request_context.meta else None + progress_token = request_context.meta.get("progress_token") if request_context.meta else None if progress_token is None: # Client didn't request progress updates @@ -86,7 +86,7 @@ async def progress_task_no_message(steps: int = 3) -> str: assert request_context is not None # Get the progress token from the request metadata - progress_token = request_context.meta.progressToken if request_context.meta else None + progress_token = request_context.meta.get("progress_token") if request_context.meta else None if progress_token is None: # Client didn't request progress updates @@ -119,7 +119,7 @@ async def send_progress( """Helper function that correctly sends progress with related_request_id.""" request_context = context.request_context assert request_context is not None - progress_token = request_context.meta.progressToken if request_context.meta else None + progress_token = request_context.meta.progress_token if request_context.meta else None if progress_token is None: return diff --git a/tests/integration/api/mcp_tools_server.py b/tests/integration/api/mcp_tools_server.py index d38cac2e6..16acddfea 100644 --- a/tests/integration/api/mcp_tools_server.py +++ b/tests/integration/api/mcp_tools_server.py @@ -45,7 +45,7 @@ def shirt_colour() -> str: @app.tool(name="implementation", description="Returns the Client implementation") def implementation(ctx: Context) -> str: assert ctx.session.client_params is not None, "Client params should not be None" - client_info = ctx.session.client_params.clientInfo + client_info = ctx.session.client_params.client_info assert client_info is not None return client_info.model_dump_json() diff --git a/tests/integration/api/test_api.py b/tests/integration/api/test_api.py index 2a6309eec..5b899f604 100644 --- a/tests/integration/api/test_api.py +++ b/tests/integration/api/test_api.py @@ -96,7 +96,7 @@ async def agent_function(): @pytest.mark.asyncio async def test_mixed_message_types(fast_agent): """Test that the agent can handle mixed message types seamlessly.""" - from mcp.types import PromptMessage, TextContent + from mcp_types import PromptMessage, TextContent from fast_agent.mcp.prompt import Prompt from fast_agent.mcp.prompt_message_extended import PromptMessageExtended @@ -172,14 +172,13 @@ async def agent_function(): assert "simulated prompt result" == response.first_text() # Test with EmbeddedResource directly in Prompt.user() - from mcp.types import EmbeddedResource, TextResourceContents - from pydantic import AnyUrl + from mcp_types import EmbeddedResource, TextResourceContents # Create a resource text_resource = TextResourceContents( - uri=AnyUrl("file:///test/example.txt"), + uri="file:///test/example.txt", text="Sample text from resource", - mimeType="text/plain", + mime_type="text/plain", ) embedded_resource = EmbeddedResource(type="resource", resource=text_resource) @@ -508,7 +507,7 @@ async def agent_function(): @pytest.mark.asyncio async def test_mixed_prompt_message_and_multipart(fast_agent): """Test that the agent can process a mixed list of PromptMessage and PromptMessageExtended.""" - from mcp.types import PromptMessage, TextContent + from mcp_types import PromptMessage, TextContent from fast_agent.mcp.prompt_message_extended import PromptMessageExtended @@ -545,7 +544,7 @@ async def agent_function(): @pytest.mark.asyncio async def test_generate_with_various_input_types(fast_agent): """Test that generate() accepts strings, PromptMessage, PromptMessageExtended, and lists.""" - from mcp.types import PromptMessage, TextContent + from mcp_types import PromptMessage, TextContent from fast_agent.mcp.prompt import Prompt diff --git a/tests/integration/api/test_cli_and_mcp_server.py b/tests/integration/api/test_cli_and_mcp_server.py index ae3707d96..5ee8a82e9 100644 --- a/tests/integration/api/test_cli_and_mcp_server.py +++ b/tests/integration/api/test_cli_and_mcp_server.py @@ -4,8 +4,9 @@ import sys import httpx +import mcp_types as types import pytest -from mcp import ClientSession, types +from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from fast_agent.mcp.helpers.content_helpers import get_text @@ -277,7 +278,6 @@ async def test_serve_request_scope_disables_session_header(mcp_test_ports, wait_ async with streamable_http_client(f"http://127.0.0.1:{port}/mcp") as ( read_stream, write_stream, - _, ): async with ClientSession(read_stream, write_stream) as session: init_result = await session.initialize() @@ -443,7 +443,6 @@ async def on_progress(progress: float, total: float | None, message: str | None) async with streamable_http_client(f"http://127.0.0.1:{port}/mcp") as ( read_stream, write_stream, - _, ): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -452,7 +451,7 @@ async def on_progress(progress: float, total: float | None, message: str | None) ) request = types.CallToolRequest(method="tools/call", params=params) result = await session.send_request( - types.ClientRequest(request), + request, types.CallToolResult, progress_callback=on_progress, ) diff --git a/tests/integration/api/test_mcp_auth_passthrough.py b/tests/integration/api/test_mcp_auth_passthrough.py index 18e9588bb..d85bbea9f 100644 --- a/tests/integration/api/test_mcp_auth_passthrough.py +++ b/tests/integration/api/test_mcp_auth_passthrough.py @@ -1,7 +1,7 @@ from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager, contextmanager -import httpx +import httpx2 import pytest from fastmcp.server.auth import AccessToken from mcp import ClientSession @@ -107,15 +107,15 @@ async def _call_send_tool( ) async with starlette_app.router.lifespan_context(starlette_app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=transport_app), + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=transport_app), base_url="http://testserver", headers=headers, ) as client: async with streamable_http_client( "http://testserver/mcp", http_client=client, - ) as (read_stream, write_stream, _): + ) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() result = await session.call_tool("worker", {"message": "hello"}) diff --git a/tests/integration/api/test_parallel_tool_progress_display.py b/tests/integration/api/test_parallel_tool_progress_display.py index 964a49f3f..4c870b70b 100644 --- a/tests/integration/api/test_parallel_tool_progress_display.py +++ b/tests/integration/api/test_parallel_tool_progress_display.py @@ -100,7 +100,7 @@ async def run_one(index: int) -> None: {"steps": 5}, tool_use_id=f"parallel-tool-{index}", ) - assert not result.isError + assert not result.is_error await asyncio.gather(*(run_one(i) for i in range(3))) diff --git a/tests/integration/edit/test_edit_file_tool.py b/tests/integration/edit/test_edit_file_tool.py index 750529d1c..2f4fe9913 100644 --- a/tests/integration/edit/test_edit_file_tool.py +++ b/tests/integration/edit/test_edit_file_tool.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING import pytest -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.mcp_agent import McpAgent @@ -39,7 +39,7 @@ def _build_agent(tmp_path: Path) -> McpAgent: def _result_payload(result: CallToolResult) -> dict[str, object]: - payload = result.structuredContent + payload = result.structured_content assert isinstance(payload, dict) return payload @@ -88,7 +88,7 @@ async def test_edit_file_replaces_unique_multiline_match_and_returns_structured_ ) payload = _result_payload(result) - assert result.isError is False + assert result.is_error is False assert _read_text(project_file) == ( 'def hello():\n print("hello")\n' ) @@ -124,7 +124,7 @@ async def test_edit_file_replace_all_updates_each_non_overlapping_match(tmp_path ) payload = _result_payload(result) - assert result.isError is False + assert result.is_error is False assert _read_text(target_file) == "item one\nitem two\n" assert payload["success"] is True assert payload["replacements"] == 2 @@ -152,7 +152,7 @@ async def test_edit_file_reports_multiple_matches_with_line_locations(tmp_path: ) payload = _result_payload(result) - assert result.isError is True + assert result.is_error is True assert _read_text(target_file) == "alpha\nvalue\nbeta\nvalue\n" assert payload == { "success": False, @@ -191,7 +191,7 @@ async def test_edit_file_reports_no_match_without_modifying_file(tmp_path: Path) ) payload = _result_payload(result) - assert result.isError is True + assert result.is_error is True assert _read_text(target_file) == "alpha\nbeta\n" assert payload == { "success": False, @@ -267,7 +267,7 @@ async def test_edit_file_deletion_preserves_missing_trailing_newline(tmp_path: P ) payload = _result_payload(result) - assert result.isError is False + assert result.is_error is False assert _read_text(target_file) == "alpha\nomega" assert payload["success"] is True assert payload["line_start"] == 2 @@ -296,7 +296,7 @@ async def test_edit_file_can_replace_entire_file_and_preserve_trailing_newline( ) payload = _result_payload(result) - assert result.isError is False + assert result.is_error is False assert _read_text(target_file) == "gamma\ndelta\n" assert payload["success"] is True assert payload["line_start"] == 1 @@ -323,7 +323,7 @@ async def test_edit_file_can_delete_entire_file_to_empty_contents(tmp_path: Path ) payload = _result_payload(result) - assert result.isError is False + assert result.is_error is False assert _read_text(target_file) == "" assert payload["success"] is True assert payload["line_start"] == 1 @@ -354,7 +354,7 @@ async def test_edit_file_reports_multiline_ambiguity_with_match_locations(tmp_pa ) payload = _result_payload(result) - assert result.isError is True + assert result.is_error is True assert payload == { "success": False, "error": "multiple_matches", @@ -400,7 +400,7 @@ async def test_edit_file_is_whitespace_exact_and_supports_unicode_success(tmp_pa }, ) - assert whitespace_result.isError is True + assert whitespace_result.is_error is True assert _result_payload(whitespace_result) == { "success": False, "error": "no_match", @@ -410,7 +410,7 @@ async def test_edit_file_is_whitespace_exact_and_supports_unicode_success(tmp_pa ), "path": "indent.py", } - assert unicode_result.isError is False + assert unicode_result.is_error is False assert _read_text(unicode_file) == "naïve\n" finally: await agent._aggregator.close() @@ -436,7 +436,7 @@ async def test_edit_file_preserves_crlf_when_matching_exact_windows_newlines( ) payload = _result_payload(result) - assert result.isError is False + assert result.is_error is False assert target_file.read_bytes() == b"gamma\r\ndelta\r\n" assert payload["success"] is True assert payload["line_start"] == 1 diff --git a/tests/integration/elicitation/elicitation_test_server_advanced.py b/tests/integration/elicitation/elicitation_test_server_advanced.py index 95e3e0294..c5b91a56b 100644 --- a/tests/integration/elicitation/elicitation_test_server_advanced.py +++ b/tests/integration/elicitation/elicitation_test_server_advanced.py @@ -66,9 +66,9 @@ async def client_capabilities_resource() -> str: text = "Client Capabilities:\n" + "\n".join(capabilities_list) # Add client info for debugging - client_info = ctx.session.client_params.clientInfo + client_info = ctx.session.client_params.client_info text += f"\n\nClient Info: {client_info.name} v{client_info.version}" - text += f"\nProtocol Version: {ctx.session.client_params.protocolVersion}" + text += f"\nProtocol Version: {ctx.session.client_params.protocol_version}" return text diff --git a/tests/integration/elicitation/test_elicitation_handler.py b/tests/integration/elicitation/test_elicitation_handler.py index 13b29a0c0..0c343c1d8 100644 --- a/tests/integration/elicitation/test_elicitation_handler.py +++ b/tests/integration/elicitation/test_elicitation_handler.py @@ -5,29 +5,26 @@ to verify custom handler functionality. """ -from typing import TYPE_CHECKING, Any +from typing import Any -from mcp.shared.context import RequestContext -from mcp.types import ElicitRequestParams, ElicitResult +from mcp.client.session import ClientRequestContext +from mcp_types import ElicitRequestParams, ElicitResult from fast_agent.core.logging.logger import get_logger -if TYPE_CHECKING: - from mcp import ClientSession - logger = get_logger(__name__) type ElicitationContent = dict[str, str | int | float | list[str] | None] async def custom_elicitation_handler( - context: RequestContext["ClientSession", Any], + context: ClientRequestContext, params: ElicitRequestParams, ) -> ElicitResult: """Test handler that returns predictable responses for integration testing.""" logger.info(f"Test elicitation handler called with: {params.message}") - requested_schema = getattr(params, "requestedSchema", None) + requested_schema = getattr(params, "requested_schema", None) if requested_schema: # Generate test data based on the schema for round-trip verification properties = requested_schema.get("properties", {}) diff --git a/tests/integration/elicitation/test_elicitation_integration.py b/tests/integration/elicitation/test_elicitation_integration.py index 178163c45..8a4d2ebb6 100644 --- a/tests/integration/elicitation/test_elicitation_integration.py +++ b/tests/integration/elicitation/test_elicitation_integration.py @@ -7,30 +7,27 @@ 3. Elicitation capabilities are properly advertised to servers """ -from typing import TYPE_CHECKING, Any +from typing import Any import pytest -from mcp.shared.context import RequestContext -from mcp.types import ElicitRequestParams, ElicitResult +from mcp.client.session import ClientRequestContext +from mcp_types import ElicitRequestParams, ElicitResult from fast_agent.core.logging.logger import get_logger -if TYPE_CHECKING: - from mcp import ClientSession - logger = get_logger(__name__) type ElicitationContent = dict[str, str | int | float | list[str] | None] async def custom_test_elicitation_handler( - context: RequestContext["ClientSession", Any], + context: ClientRequestContext, params: ElicitRequestParams, ) -> ElicitResult: """Test handler that returns predictable responses for integration testing.""" logger.info(f"Test elicitation handler called with: {params.message}") - requested_schema = getattr(params, "requestedSchema", None) + requested_schema = getattr(params, "requested_schema", None) if requested_schema: # Generate test data based on the schema for round-trip verification properties = requested_schema.get("properties", {}) diff --git a/tests/integration/elicitation/test_url_elicitation_required_error.py b/tests/integration/elicitation/test_url_elicitation_required_error.py index 89f71af2e..fdc513f94 100644 --- a/tests/integration/elicitation/test_url_elicitation_required_error.py +++ b/tests/integration/elicitation/test_url_elicitation_required_error.py @@ -19,7 +19,7 @@ async def test_url_elicitation_required_error_displays_required_elicitations(fas async def agent_function(): async with fast.run() as agent: result = await agent["url_required_valid_agent"].call_tool("url_required_valid_tool") - assert result.isError is True + assert result.is_error is True payload = url_elicitation_required_payload(result) assert payload is not None assert len(payload.elicitations) == 2 @@ -43,7 +43,7 @@ async def agent_function(): result = await agent["url_required_malformed_agent"].call_tool( "url_required_malformed_tool" ) - assert result.isError is True + assert result.is_error is True payload = url_elicitation_required_payload(result) assert payload is not None assert len(payload.elicitations) == 0 diff --git a/tests/integration/elicitation/url_elicitation_required_server.py b/tests/integration/elicitation/url_elicitation_required_server.py index f880e9d17..6e3e3d176 100644 --- a/tests/integration/elicitation/url_elicitation_required_server.py +++ b/tests/integration/elicitation/url_elicitation_required_server.py @@ -3,7 +3,7 @@ import json from fastmcp import FastMCP -from mcp.types import ElicitRequestURLParams +from mcp_types import ElicitRequestURLParams mcp = FastMCP("URL Elicitation Required Test Server") _PREFIX = "fast-agent-url-elicitation-required:" @@ -21,13 +21,13 @@ async def url_required_valid_tool() -> str: mode="url", message="Complete authorization to continue.", url="https://example.com/auth/first", - elicitationId="valid-1", + elicitation_id="valid-1", ).model_dump(by_alias=True), ElicitRequestURLParams( mode="url", message="Confirm payment in your browser.", url="https://example.com/pay/second", - elicitationId="valid-2", + elicitation_id="valid-2", ).model_dump(by_alias=True), ] } diff --git a/tests/integration/function_tools/test_function_tools.py b/tests/integration/function_tools/test_function_tools.py index 3ac900bed..7827f6595 100644 --- a/tests/integration/function_tools/test_function_tools.py +++ b/tests/integration/function_tools/test_function_tools.py @@ -11,8 +11,8 @@ async def test_function_tools_from_card(fast_agent): tools = await agent.calc.list_tools() assert any(t.name == "add" for t in tools.tools) result = await agent.calc.call_tool("add", {"a": 2, "b": 3}) - assert result.isError is False - assert result.structuredContent is None + assert result.is_error is False + assert result.structured_content is None assert result.content is not None assert result.content[0].text == "5" @@ -42,14 +42,14 @@ async def calc_decorator(): assert any(t.name == "summarize" for t in tools.tools) add_result = await agent.calc_decorator.call_tool("add", {"a": 4, "b": 5}) - assert add_result.isError is False - assert add_result.structuredContent is None + assert add_result.is_error is False + assert add_result.structured_content is None assert add_result.content is not None assert add_result.content[0].text == "9" summarize_result = await agent.calc_decorator.call_tool("summarize", {}) - assert summarize_result.isError is False - assert summarize_result.structuredContent is None + assert summarize_result.is_error is False + assert summarize_result.structured_content is None assert summarize_result.content is not None assert summarize_result.content[0].text == '{"status":"ok"}' @@ -75,8 +75,8 @@ async def custom_calc_decorator(): assert any(t.name == "add" for t in tools.tools) add_result = await agent.custom_calc_decorator.call_tool("add", {"a": 6, "b": 7}) - assert add_result.isError is False - assert add_result.structuredContent is None + assert add_result.is_error is False + assert add_result.structured_content is None assert add_result.content is not None assert add_result.content[0].text == "13" @@ -102,7 +102,7 @@ async def custom_global_tools(): assert any(t.name == "ping" for t in tools.tools) ping_result = await agent.custom_global_tools.call_tool("ping", {}) - assert ping_result.isError is False - assert ping_result.structuredContent is None + assert ping_result.is_error is False + assert ping_result.structured_content is None assert ping_result.content is not None assert ping_result.content[0].text == "pong" diff --git a/tests/integration/instruction_templates/test_file_silent_instruction.py b/tests/integration/instruction_templates/test_file_silent_instruction.py index 0ba63dcb0..3f61e658c 100644 --- a/tests/integration/instruction_templates/test_file_silent_instruction.py +++ b/tests/integration/instruction_templates/test_file_silent_instruction.py @@ -25,12 +25,12 @@ async def agent_function(): # The LLM request params (what the provider sees) should also be resolved request_params = agent.llm.get_request_params() - assert request_params.systemPrompt is not None - assert "{{file_silent:FOO.md}}" not in request_params.systemPrompt - assert file_text in request_params.systemPrompt + assert request_params.system_prompt is not None + assert "{{file_silent:FOO.md}}" not in request_params.system_prompt + assert file_text in request_params.system_prompt # Default params should stay in sync for future calls - assert file_text in agent.llm.default_request_params.systemPrompt + assert file_text in agent.llm.default_request_params.system_prompt response = await agent.send("ping") assert "ping" in response diff --git a/tests/integration/mcp_2026/fastagent.config.yaml b/tests/integration/mcp_2026/fastagent.config.yaml new file mode 100644 index 000000000..5d40895c8 --- /dev/null +++ b/tests/integration/mcp_2026/fastagent.config.yaml @@ -0,0 +1,17 @@ +default_model: passthrough + +logger: + level: error + type: console + +mcp: + servers: + modern: + command: uv + args: [run, mrtr_server.py] + transport: stdio + elicitation: + mode: none + modern_http: + url: http://127.0.0.1:${FAST_AGENT_TEST_HTTP_PORT}/mcp + transport: http diff --git a/tests/integration/mcp_2026/mrtr_server.py b/tests/integration/mcp_2026/mrtr_server.py new file mode 100644 index 000000000..f9ea786ce --- /dev/null +++ b/tests/integration/mcp_2026/mrtr_server.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from mcp.server.mcpserver import Elicit, MCPServer, Resolve +from pydantic import BaseModel + + +class Profile(BaseModel): + name: str + age: int + + +def ask_profile() -> Elicit[Profile]: + return Elicit("Provide a profile", Profile) + + +server = MCPServer("modern-mrtr") + + +@server.tool() +def create_profile(profile: Annotated[Profile, Resolve(ask_profile)]) -> str: + return f"{profile.name}:{profile.age}" + + +@server.resource("modern://status") +def status() -> str: + return "modern-ok" + + +@server.prompt() +def hello(name: str = "world") -> str: + return f"Hello, {name}" + + +if __name__ == "__main__": + server.run(transport="stdio") diff --git a/tests/integration/mcp_2026/subscription_server.py b/tests/integration/mcp_2026/subscription_server.py new file mode 100644 index 000000000..21e8caccf --- /dev/null +++ b/tests/integration/mcp_2026/subscription_server.py @@ -0,0 +1,25 @@ +import os + +from mcp.server.mcpserver import Context, MCPServer + +server = MCPServer("modern-subscriptions") + + +@server.tool() +async def add_dynamic_tool(ctx: Context) -> str: + if "dynamic_echo" not in {tool.name for tool in await ctx.mcp_server.list_tools()}: + + @ctx.mcp_server.tool(name="dynamic_echo") + def dynamic_echo(message: str) -> str: + return message + + await ctx.notify_tools_changed() + return "added" + + +if __name__ == "__main__": + server.run( + transport="streamable-http", + host="127.0.0.1", + port=int(os.environ["FAST_AGENT_TEST_HTTP_PORT"]), + ) diff --git a/tests/integration/mcp_2026/test_modern_mrtr.py b/tests/integration/mcp_2026/test_modern_mrtr.py new file mode 100644 index 000000000..34f3f65c9 --- /dev/null +++ b/tests/integration/mcp_2026/test_modern_mrtr.py @@ -0,0 +1,52 @@ +import pytest +from mcp.client.session import ClientRequestContext +from mcp_types import ElicitRequestFormParams, ElicitRequestParams, ElicitResult + +from fast_agent.mcp.helpers.content_helpers import get_text + + +async def accept_profile( + context: ClientRequestContext, + params: ElicitRequestParams, +) -> ElicitResult: + del context + assert params.message == "Provide a profile" + assert isinstance(params, ElicitRequestFormParams) + assert params.requested_schema["required"] == ["name", "age"] + return ElicitResult(action="accept", content={"name": "Ada", "age": 37}) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_modern_negotiation_and_mrtr(fast_agent) -> None: + @fast_agent.agent( + name="probe", + model="passthrough", + servers=["modern"], + elicitation_handler=accept_profile, + ) + async def run_probe() -> None: + async with fast_agent.run() as app: + result = await app.probe.call_tool("create_profile", {}) + + assert result.is_error is False + assert get_text(result.content[0]) == "Ada:37" + resource = await app.probe.get_resource("modern://status", "modern") + assert get_text(resource.contents[0]) == "modern-ok" + prompt = await app.probe.get_prompt( + "hello", + {"name": "fast-agent"}, + server_name="modern", + ) + assert get_text(prompt.messages[0].content) == "Hello, fast-agent" + + status = (await app.probe.get_server_status())["modern"] + assert status.protocol_version == "2026-07-28" + assert status.protocol_era == "modern" + assert status.supported_protocol_versions == ("2026-07-28",) + assert status.negotiation == "discover" + assert status.subscription_state == "unsupported" + assert status.ping_ok_count == 0 + assert status.ping_fail_count == 0 + + await run_probe() diff --git a/tests/integration/mcp_2026/test_modern_subscriptions.py b/tests/integration/mcp_2026/test_modern_subscriptions.py new file mode 100644 index 000000000..24b4ed0e0 --- /dev/null +++ b/tests/integration/mcp_2026/test_modern_subscriptions.py @@ -0,0 +1,51 @@ +import asyncio +import os +import subprocess + +import pytest + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_modern_tool_subscription_refreshes_cache( + fast_agent, + mcp_test_ports, + wait_for_port, +) -> None: + process = subprocess.Popen( + ["uv", "run", "subscription_server.py"], + cwd=os.path.dirname(__file__), + env=os.environ.copy(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + await wait_for_port("127.0.0.1", mcp_test_ports["http"], process=process) + + @fast_agent.agent(name="probe", model="passthrough", servers=["modern_http"]) + async def run_probe() -> None: + async with fast_agent.run() as app: + before = {tool.name for tool in (await app.probe.list_tools()).tools} + assert "modern_http__dynamic_echo" not in before + + status = (await app.probe.get_server_status())["modern_http"] + assert status.protocol_era == "modern" + assert status.subscription_state == "open" + + await app.probe.call_tool("add_dynamic_tool", {}) + for _ in range(50): + await asyncio.sleep(0.1) + after = {tool.name for tool in (await app.probe.list_tools()).tools} + if "modern_http__dynamic_echo" in after: + break + else: + pytest.fail("tools/list cache was not refreshed by the subscription event") + + status = (await app.probe.get_server_status())["modern_http"] + assert status.subscription_state == "open" + + await run_probe() + finally: + process.terminate() + process.communicate(timeout=5) diff --git a/tests/integration/mcp_ui/test_mcp_ui_integration.py b/tests/integration/mcp_ui/test_mcp_ui_integration.py index cf4acd162..8113b3074 100644 --- a/tests/integration/mcp_ui/test_mcp_ui_integration.py +++ b/tests/integration/mcp_ui/test_mcp_ui_integration.py @@ -3,8 +3,7 @@ import pytest import pytest_asyncio -from mcp.types import EmbeddedResource, TextContent, TextResourceContents -from pydantic import AnyUrl +from mcp_types import EmbeddedResource, TextContent, TextResourceContents from fast_agent.agents.agent_types import AgentConfig from fast_agent.constants import MCP_UI @@ -48,8 +47,8 @@ async def test_mcp_ui_html_write_and_link_display(passthrough_agent): ui_res = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("ui://my-component/instance-1"), - mimeType="text/html", + uri="ui://my-component/instance-1", + mime_type="text/html", text="

Hello World

", ), ) diff --git a/tests/integration/prompt-server/test_prompt_server_integration.py b/tests/integration/prompt-server/test_prompt_server_integration.py index 58ae050f2..f09f76374 100644 --- a/tests/integration/prompt-server/test_prompt_server_integration.py +++ b/tests/integration/prompt-server/test_prompt_server_integration.py @@ -7,7 +7,7 @@ from fast_agent.mcp.prompt_message_extended import PromptMessageExtended if TYPE_CHECKING: - from mcp.types import GetPromptResult, Prompt + from mcp_types import GetPromptResult, Prompt @pytest.mark.integration diff --git a/tests/integration/prompt-state/test_load_prompt_templates.py b/tests/integration/prompt-state/test_load_prompt_templates.py index 581a0b8ee..0e0ffd362 100644 --- a/tests/integration/prompt-state/test_load_prompt_templates.py +++ b/tests/integration/prompt-state/test_load_prompt_templates.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import pytest -from mcp.types import ImageContent +from mcp_types import ImageContent from fast_agent.constants import CONTROL_MESSAGE_SAVE_HISTORY from fast_agent.mcp.prompt import Prompt @@ -54,7 +54,7 @@ async def agent_function(): assert 5 == len(prompts) assert "user" == prompts[0].role - assert "text/css" == prompts[0].content[1].resource.mimeType # type: ignore + assert "text/css" == prompts[0].content[1].resource.mime_type # type: ignore assert "f5f5f5" in prompts[0].content[1].resource.text # type: ignore assert "assistant" == prompts[1].role @@ -101,7 +101,7 @@ async def test_save_state_to_mcp_json_format(fast_agent): """Test saving conversation history to a JSON file in MCP wire format. This should create a file that's compatible with the MCP SDK and can be loaded directly using Pydantic types.""" - from mcp.types import GetPromptResult + from mcp_types import GetPromptResult from fast_agent.mcp.prompt_serialization import from_json @@ -272,7 +272,7 @@ async def agent_function(): @pytest.mark.asyncio async def test_apply_prompt_with_prompt_result_object(fast_agent): """Test that we can apply a GetPromptResult object directly with as_template.""" - from mcp.types import GetPromptResult, PromptMessage, TextContent + from mcp_types import GetPromptResult, PromptMessage, TextContent fast = fast_agent diff --git a/tests/integration/resources/mcp_linked_resouce_server.py b/tests/integration/resources/mcp_linked_resouce_server.py index 54c356fd3..c056ba0ae 100644 --- a/tests/integration/resources/mcp_linked_resouce_server.py +++ b/tests/integration/resources/mcp_linked_resouce_server.py @@ -6,8 +6,7 @@ import logging from fastmcp import FastMCP -from mcp.types import ResourceLink -from pydantic import AnyUrl +from mcp_types import ResourceLink logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -21,9 +20,9 @@ def getlink() -> ResourceLink: return ResourceLink( name="linked resource", type="resource_link", - uri=AnyUrl("resource://fast-agent/linked-resource"), + uri="resource://fast-agent/linked-resource", description="A description, perhaps for the LLM", - mimeType="text/plain", + mime_type="text/plain", ) diff --git a/tests/integration/resources/mcp_resource_template_server.py b/tests/integration/resources/mcp_resource_template_server.py index c2ded8e2e..b2acca56c 100644 --- a/tests/integration/resources/mcp_resource_template_server.py +++ b/tests/integration/resources/mcp_resource_template_server.py @@ -4,7 +4,7 @@ from __future__ import annotations from fastmcp import FastMCP -from mcp.types import Completion, ResourceTemplateReference +from mcp_types import Completion, ResourceTemplateReference mcp = FastMCP("Smart Resource Template Test Server") @@ -36,7 +36,7 @@ def smart_static() -> str: return "static" -@mcp._mcp_server.completion() +@mcp.completion() async def complete_resource_template_argument(ref, argument, context): del context if not isinstance(ref, ResourceTemplateReference): @@ -46,7 +46,7 @@ async def complete_resource_template_argument(ref, argument, context): prefix = argument.value or "" values = [name for name in sorted(_ITEMS.keys()) if name.startswith(prefix)] - return Completion(values=values, total=len(_ITEMS), hasMore=False) + return Completion(values=values, total=len(_ITEMS), has_more=False) if __name__ == "__main__": diff --git a/tests/integration/resources/test_resource_api.py b/tests/integration/resources/test_resource_api.py index efc06586c..a9d9c2593 100644 --- a/tests/integration/resources/test_resource_api.py +++ b/tests/integration/resources/test_resource_api.py @@ -3,7 +3,7 @@ """ import pytest -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError as McpError from fast_agent.mcp.prompts.prompt_helpers import get_text diff --git a/tests/integration/roots/root_client.py b/tests/integration/roots/root_client.py index 39bdf08ce..32823c9e2 100644 --- a/tests/integration/roots/root_client.py +++ b/tests/integration/roots/root_client.py @@ -1,7 +1,7 @@ import anyio from mcp.client.session import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import ListRootsResult, Root +from mcp_types import ListRootsResult, Root from pydantic import FileUrl diff --git a/tests/integration/roots/root_test_server.py b/tests/integration/roots/root_test_server.py index ec7e28839..d96eed0f6 100644 --- a/tests/integration/roots/root_test_server.py +++ b/tests/integration/roots/root_test_server.py @@ -3,7 +3,7 @@ from fastmcp import Context, FastMCP if TYPE_CHECKING: - from mcp.types import ListRootsResult + from mcp_types import ListRootsResult mcp = FastMCP("MCP Root Tester") diff --git a/tests/integration/sampling/sampling_test_server.py b/tests/integration/sampling/sampling_test_server.py index 1bd8c1693..551ca9637 100644 --- a/tests/integration/sampling/sampling_test_server.py +++ b/tests/integration/sampling/sampling_test_server.py @@ -7,7 +7,7 @@ from fastmcp import Context, FastMCP from fastmcp.tools import ToolResult -from mcp.types import SamplingMessage, TextContent +from mcp_types import SamplingMessage, TextContent # Configure detailed logging logging.basicConfig( diff --git a/tests/integration/sampling_with_tools/sampling_tools_test_server.py b/tests/integration/sampling_with_tools/sampling_tools_test_server.py index 1055714a3..10d0cece1 100644 --- a/tests/integration/sampling_with_tools/sampling_tools_test_server.py +++ b/tests/integration/sampling_with_tools/sampling_tools_test_server.py @@ -9,7 +9,7 @@ from fastmcp import Context, FastMCP from fastmcp.tools import ToolResult -from mcp.types import ( +from mcp_types import ( AudioContent, ImageContent, SamplingMessage, @@ -44,7 +44,7 @@ def _sampling_content(*blocks: SamplingMessageContentBlock) -> list[SamplingMess Tool( name="echo", description="Echo back the input", - inputSchema={ + input_schema={ "type": "object", "properties": { "message": {"type": "string", "description": "Message to echo"}, @@ -79,10 +79,10 @@ async def test_sampling_with_tools(ctx: Context, message: str) -> ToolResult: tool_choice=ToolChoice(mode="auto"), ) - logger.info(f"Received result: stopReason={result.stopReason}") + logger.info(f"Received result: stopReason={result.stop_reason}") # Return info about what we received - info = f"stopReason={result.stopReason}, model={result.model}" + info = f"stopReason={result.stop_reason}, model={result.model}" return ToolResult(content=[TextContent(type="text", text=f"Sampling completed: {info}")]) @@ -103,7 +103,7 @@ async def test_sampling_without_tools(ctx: Context, message: str) -> ToolResult: ], ) - logger.info(f"Received result: stopReason={result.stopReason}") + logger.info(f"Received result: stopReason={result.stop_reason}") # Extract text from result if isinstance(result.content, TextContent): @@ -139,11 +139,11 @@ async def test_tool_result_handling(ctx: Context) -> ToolResult: tool_choice=ToolChoice(mode="required"), # Force tool use ) - logger.info(f"First result: stopReason={result.stopReason}") + logger.info(f"First result: stopReason={result.stop_reason}") # With passthrough model, we might not get a tool use response # Just verify we got a response - if result.stopReason == "toolUse": + if result.stop_reason == "toolUse": # Extract tool uses tool_uses = [] if isinstance(result.content, list): @@ -157,7 +157,7 @@ async def test_tool_result_handling(ctx: Context) -> ToolResult: *( ToolResultContent( type="tool_result", - toolUseId=tu.id, + tool_use_id=tu.id, content=[TextContent(type="text", text="echo: hello")], ) for tu in tool_uses @@ -182,14 +182,14 @@ async def test_tool_result_handling(ctx: Context) -> ToolResult: content=[ TextContent( type="text", - text=f"Multi-turn completed: first={result.stopReason}, final={final_result.stopReason}", + text=f"Multi-turn completed: first={result.stop_reason}, final={final_result.stop_reason}", ) ] ) # Single turn response return ToolResult( - content=[TextContent(type="text", text=f"Single turn: stopReason={result.stopReason}")] + content=[TextContent(type="text", text=f"Single turn: stopReason={result.stop_reason}")] ) diff --git a/tests/integration/tool_hooks/test_tool_hooks.py b/tests/integration/tool_hooks/test_tool_hooks.py index d93308208..3c6010f4a 100644 --- a/tests/integration/tool_hooks/test_tool_hooks.py +++ b/tests/integration/tool_hooks/test_tool_hooks.py @@ -2,7 +2,7 @@ import pytest from mcp import CallToolRequest, Tool -from mcp.types import CallToolRequestParams +from mcp_types import CallToolRequestParams from fast_agent.llm.internal.passthrough import PassthroughLLM from fast_agent.llm.request_params import RequestParams diff --git a/tests/integration/tool_loop/test_tool_loop.py b/tests/integration/tool_loop/test_tool_loop.py index d5aa4b9f4..f29999bf1 100644 --- a/tests/integration/tool_loop/test_tool_loop.py +++ b/tests/integration/tool_loop/test_tool_loop.py @@ -2,7 +2,7 @@ import pytest from mcp import CallToolRequest, Tool -from mcp.types import CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequestParams, CallToolResult, TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent diff --git a/tests/integration/ui/test_command_dispatch_flows.py b/tests/integration/ui/test_command_dispatch_flows.py index a804a980c..588c0793f 100644 --- a/tests/integration/ui/test_command_dispatch_flows.py +++ b/tests/integration/ui/test_command_dispatch_flows.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.config import get_settings, update_global_settings from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/scripts/harvest_llm_traces.py b/tests/scripts/harvest_llm_traces.py index 2924f5cb7..78d892d4c 100644 --- a/tests/scripts/harvest_llm_traces.py +++ b/tests/scripts/harvest_llm_traces.py @@ -57,7 +57,7 @@ class HarvestCapture: WEATHER_TOOL = Tool( name="weather", description="Check the weather in a city", - inputSchema={ + input_schema={ "type": "object", "properties": { "city": { @@ -412,7 +412,7 @@ async def _run_scenario( max_tokens_override if max_tokens_override is not None else scenario.max_tokens ) request_params = ( - RequestParams(maxTokens=effective_max_tokens) + RequestParams(max_tokens=effective_max_tokens) if effective_max_tokens is not None else None ) diff --git a/tests/unit/acp/test_agent_acp_server_sessions.py b/tests/unit/acp/test_agent_acp_server_sessions.py index 0f0648930..baa024d81 100644 --- a/tests/unit/acp/test_agent_acp_server_sessions.py +++ b/tests/unit/acp/test_agent_acp_server_sessions.py @@ -10,7 +10,7 @@ import pytest from acp.exceptions import RequestError from acp.schema import AgentMessageChunk, SessionModeState, UserMessageChunk -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.acp.server.agent_acp_server import ACPSessionState, AgentACPServer from fast_agent.acp.server.session_store import ACPServerSessionStore, SessionStoreHost diff --git a/tests/unit/acp/test_content_conversion.py b/tests/unit/acp/test_content_conversion.py index 9e2b45074..1203e8c0b 100644 --- a/tests/unit/acp/test_content_conversion.py +++ b/tests/unit/acp/test_content_conversion.py @@ -16,21 +16,21 @@ TextContentBlock, TextResourceContents, ) -from mcp.types import AudioContent as MCPAudioContent -from mcp.types import ( +from mcp_types import AudioContent as MCPAudioContent +from mcp_types import ( BlobResourceContents as MCPBlobResourceContents, ) -from mcp.types import ( +from mcp_types import ( EmbeddedResource as MCPEmbeddedResource, ) -from mcp.types import ( +from mcp_types import ( ImageContent as MCPImageContent, ) -from mcp.types import ResourceLink as MCPResourceLink -from mcp.types import ( +from mcp_types import ResourceLink as MCPResourceLink +from mcp_types import ( TextContent as MCPTextContent, ) -from mcp.types import ( +from mcp_types import ( TextResourceContents as MCPTextResourceContents, ) @@ -100,7 +100,7 @@ def test_basic_image_conversion(self): assert isinstance(mcp_content, MCPImageContent) assert mcp_content.type == "image" assert mcp_content.data == image_data - assert mcp_content.mimeType == "image/png" + assert mcp_content.mime_type == "image/png" def test_image_with_uri(self): """Test image content with URI (should be preserved).""" @@ -117,7 +117,7 @@ def test_image_with_uri(self): assert isinstance(mcp_content, MCPImageContent) assert mcp_content.data == image_data - assert mcp_content.mimeType == "image/jpeg" + assert mcp_content.mime_type == "image/jpeg" class TestAudioContentConversion: @@ -138,7 +138,7 @@ def test_basic_audio_conversion(self): assert isinstance(mcp_content, MCPAudioContent) assert mcp_content.type == "audio" assert mcp_content.data == audio_data - assert mcp_content.mimeType == "audio/wav" + assert mcp_content.mime_type == "audio/wav" class TestResourceLinkConversion: @@ -162,7 +162,7 @@ def test_basic_resource_link_conversion(self): assert mcp_content.type == "resource_link" assert mcp_content.name == "Demo resource" assert str(mcp_content.uri) == "https://example.com/resource.pdf" - assert mcp_content.mimeType == "application/pdf" + assert mcp_content.mime_type == "application/pdf" assert mcp_content.size == 1234 assert mcp_content.description == "A resource" assert mcp_content.title == "Demo" @@ -188,7 +188,7 @@ def test_text_resource_conversion(self): assert mcp_content.type == "resource" assert isinstance(mcp_content.resource, MCPTextResourceContents) assert str(mcp_content.resource.uri) == "file:///path/to/file.py" - assert mcp_content.resource.mimeType == "text/x-python" + assert mcp_content.resource.mime_type == "text/x-python" assert mcp_content.resource.text == "def hello():\n print('Hello')" def test_blob_resource_conversion(self): @@ -210,7 +210,7 @@ def test_blob_resource_conversion(self): assert mcp_content.type == "resource" assert isinstance(mcp_content.resource, MCPBlobResourceContents) assert str(mcp_content.resource.uri) == "file:///path/to/file.pdf" - assert mcp_content.resource.mimeType == "application/pdf" + assert mcp_content.resource.mime_type == "application/pdf" assert mcp_content.resource.blob == blob_data def test_text_resource_without_mimetype(self): @@ -228,7 +228,7 @@ def test_text_resource_without_mimetype(self): assert isinstance(mcp_content, MCPEmbeddedResource) assert isinstance(mcp_content.resource, MCPTextResourceContents) assert str(mcp_content.resource.uri) == "file:///path/to/file.txt" - assert mcp_content.resource.mimeType is None + assert mcp_content.resource.mime_type is None assert mcp_content.resource.text == "Hello, world!" diff --git a/tests/unit/acp/test_filesystem_runtime.py b/tests/unit/acp/test_filesystem_runtime.py index 7bb21ab98..8cb3293b2 100644 --- a/tests/unit/acp/test_filesystem_runtime.py +++ b/tests/unit/acp/test_filesystem_runtime.py @@ -2,7 +2,7 @@ from typing import Any import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.acp.filesystem_runtime import ACPFilesystemRuntime from fast_agent.acp.tool_progress import ACPToolProgressManager @@ -174,7 +174,7 @@ async def test_write_text_file_allows_empty_content() -> None: tool_use_id="tool-1", ) - assert result.isError is False + assert result.is_error is False assert connection.writes == [{"path": "empty.txt", "content": ""}] @@ -193,8 +193,8 @@ async def test_filesystem_tools_reject_whitespace_only_path() -> None: tool_use_id="write-1", ) - assert read_result.isError is True - assert write_result.isError is True + assert read_result.is_error is True + assert write_result.is_error is True assert connection.events == [] @@ -215,7 +215,7 @@ async def test_call_tool_rejects_disabled_write_tool_without_side_effects() -> N tool_use_id="tool-1", ) - assert result.isError is True + assert result.is_error is True assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: unsupported ACP filesystem tool 'write_text_file'." assert connection.events == [] @@ -240,7 +240,7 @@ async def test_read_text_file_forwards_line_and_limit() -> None: tool_use_id="tool-1", ) - assert result.isError is False + assert result.is_error is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "old" assert connection.events == ["permission", "read"] @@ -279,7 +279,7 @@ async def test_write_text_file_sends_diff_before_write_after_permission() -> Non tool_use_id="tool-1", ) - assert result.isError is False + assert result.is_error is False assert connection.events[:5] == ["diff", "permission", "read", "diff", "write"] assert permission_handler.calls == [ ( @@ -323,7 +323,7 @@ async def test_write_text_file_in_write_only_mode_skips_old_content_read() -> No tool_use_id="tool-1", ) - assert result.isError is False + assert result.is_error is False assert connection.events == ["diff", "permission", "diff", "write"] assert connection.reads == [] assert connection.session_updates[0].content[0].old_text is None @@ -357,7 +357,7 @@ async def test_write_text_file_keeps_diff_after_progress_start_update() -> None: tool_use_id="tool-1", ) - assert result.isError is False + assert result.is_error is False content_updates = [ (index, update) for index, update in enumerate(connection.session_updates) @@ -396,7 +396,7 @@ async def test_write_text_file_denial_sends_preview_without_read_or_write() -> N tool_use_id="tool-1", ) - assert result.isError is True + assert result.is_error is True assert connection.events == ["diff", "permission"] assert len(connection.session_updates) == 1 assert connection.session_updates[0].content[0].old_text is None @@ -428,7 +428,7 @@ async def test_write_text_file_denial_without_tool_use_id_does_not_start_progres tool_use_id=None, ) - assert result.isError is True + assert result.is_error is True assert connection.events == ["permission"] assert connection.session_updates == [] assert connection.reads == [] @@ -454,7 +454,7 @@ async def test_read_text_file_denial_skips_read_and_notifies_tool_handler() -> N tool_use_id="tool-1", ) - assert result.isError is True + assert result.is_error is True assert connection.events == ["permission"] assert connection.reads == [] assert tool_handler.ensures == [ @@ -483,7 +483,7 @@ async def test_read_text_file_denial_without_tool_use_id_does_not_start_progress tool_use_id=None, ) - assert result.isError is True + assert result.is_error is True assert connection.events == ["permission"] assert connection.reads == [] assert tool_handler.starts == [] diff --git a/tests/unit/acp/test_filesystem_runtime_integration.py b/tests/unit/acp/test_filesystem_runtime_integration.py index 7a78364b0..1f7ad8fcd 100644 --- a/tests/unit/acp/test_filesystem_runtime_integration.py +++ b/tests/unit/acp/test_filesystem_runtime_integration.py @@ -5,7 +5,7 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, CallToolResult, Tool +from mcp_types import CallToolRequestParams, CallToolResult, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.mcp_agent import McpAgent @@ -38,7 +38,7 @@ def __init__(self, temp_dir: Path): self.read_tool = Tool( name="read_text_file", description="Read a text file", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": {"type": "string"}, @@ -49,7 +49,7 @@ def __init__(self, temp_dir: Path): self.write_tool = Tool( name="write_text_file", description="Write a text file", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": {"type": "string"}, @@ -75,7 +75,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[text_content(f"unsupported tool: {name}")], - isError=True, + is_error=True, ) async def read_text_file(self, arguments, tool_use_id=None): @@ -85,12 +85,12 @@ async def read_text_file(self, arguments, tool_use_id=None): content = file_path.read_text() return CallToolResult( content=[text_content(content)], - isError=False, + is_error=False, ) except Exception as e: return CallToolResult( content=[text_content(f"Error reading file: {e}")], - isError=True, + is_error=True, ) async def write_text_file(self, arguments, tool_use_id=None): @@ -102,26 +102,26 @@ async def write_text_file(self, arguments, tool_use_id=None): file_path.write_text(content) return CallToolResult( content=[text_content(f"Successfully wrote {len(content)} characters to {path}")], - isError=False, + is_error=False, ) except Exception as e: return CallToolResult( content=[text_content(f"Error writing file: {e}")], - isError=True, + is_error=True, ) async def apply_patch(self, arguments, tool_use_id=None): del arguments, tool_use_id return CallToolResult( content=[text_content("apply_patch unsupported")], - isError=True, + is_error=True, ) async def edit_file(self, arguments, tool_use_id=None): del arguments, tool_use_id return CallToolResult( content=[text_content("edit_file unsupported")], - isError=True, + is_error=True, ) def metadata(self): @@ -172,7 +172,7 @@ async def test_filesystem_runtime_tool_call(): # Call read_text_file result = await agent.call_tool("read_text_file", {"path": str(test_file)}) - assert result.isError is False + assert result.is_error is False assert len(result.content) > 0 assert test_content in result.content[0].text @@ -183,7 +183,7 @@ async def test_filesystem_runtime_tool_call(): "write_text_file", {"path": str(output_file), "content": write_content} ) - assert result.isError is False + assert result.is_error is False assert "Successfully wrote" in result.content[0].text # Verify the file was actually written diff --git a/tests/unit/acp/test_terminal_runtime.py b/tests/unit/acp/test_terminal_runtime.py index 6bfac9f49..906d84c2b 100644 --- a/tests/unit/acp/test_terminal_runtime.py +++ b/tests/unit/acp/test_terminal_runtime.py @@ -5,7 +5,7 @@ from typing import Any import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.acp.terminal_runtime import ACPTerminalRuntime from fast_agent.constants import DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT @@ -444,9 +444,9 @@ async def test_execute_rejects_invalid_argument_payloads() -> None: missing_command_result = await runtime.execute({}) blank_command_result = await runtime.execute({"command": " "}) - assert non_dict_result.isError is True - assert missing_command_result.isError is True - assert blank_command_result.isError is True + assert non_dict_result.is_error is True + assert missing_command_result.is_error is True + assert blank_command_result.is_error is True assert conn.calls == [] assert non_dict_result.content is not None assert missing_command_result.content is not None @@ -472,7 +472,7 @@ async def test_execute_rejects_invalid_args_before_terminal_create() -> None: result = await runtime.execute({"command": "echo", "args": "abc"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: 'args' argument must be a list of strings" @@ -488,7 +488,7 @@ async def test_execute_rejects_invalid_env_before_terminal_create() -> None: result = await runtime.execute({"command": "env", "env": {"RETRIES": 3}}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: 'env' argument must contain string keys and values" @@ -504,7 +504,7 @@ async def test_execute_rejects_empty_string_env_before_terminal_create() -> None result = await runtime.execute({"command": "env", "env": ""}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert ( @@ -523,7 +523,7 @@ async def test_execute_rejects_invalid_cwd_before_terminal_create() -> None: result = await runtime.execute({"command": "pwd", "cwd": 123}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: 'cwd' argument must be a string" @@ -539,7 +539,7 @@ async def test_execute_rejects_blank_cwd_before_terminal_create() -> None: result = await runtime.execute({"command": "pwd", "cwd": " "}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: 'cwd' argument must be a non-empty string" @@ -559,7 +559,7 @@ async def test_permission_denial_notifies_tool_progress() -> None: result = await runtime.execute({"command": "echo denied"}, tool_use_id="llm-tool-1") - assert result.isError is True + assert result.is_error is True assert conn.calls == [] assert tool_handler.starts == [] assert tool_handler.completes == [] @@ -610,7 +610,7 @@ async def test_malformed_terminal_output_is_normalized() -> None: result = await runtime.execute({"command": "echo test"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -649,7 +649,7 @@ async def test_missing_terminal_id_completes_tool_progress() -> None: result = await runtime.execute({"command": "echo test"}, tool_use_id="llm-tool-1") - assert result.isError is True + assert result.is_error is True assert [method for method, _params in conn.calls] == ["terminal/create"] assert tool_handler.starts == [ ("execute", "acp_terminal", {"command": "echo test"}, "llm-tool-1") diff --git a/tests/unit/acp/test_tool_progress.py b/tests/unit/acp/test_tool_progress.py index fa82c5987..dd2f5e680 100644 --- a/tests/unit/acp/test_tool_progress.py +++ b/tests/unit/acp/test_tool_progress.py @@ -11,8 +11,7 @@ import pytest import pytest_asyncio -from mcp.types import ResourceLink -from pydantic import AnyUrl +from mcp_types import ResourceLink from fast_agent.acp.tool_call_context import acp_tool_call_context from fast_agent.acp.tool_progress import ACPToolProgressManager @@ -315,8 +314,8 @@ async def test_on_tool_complete_preserves_resource_link_metadata(self) -> None: ResourceLink( type="resource_link", name="Spec", - uri=AnyUrl("file:///tmp/spec.md"), - mimeType="text/markdown", + uri="file:///tmp/spec.md", + mime_type="text/markdown", size=123, description="Design spec", title="Spec title", diff --git a/tests/unit/fast_agent/agents/test_agent_history_binding.py b/tests/unit/fast_agent/agents/test_agent_history_binding.py index d4a7e3b71..598e11b95 100644 --- a/tests/unit/fast_agent/agents/test_agent_history_binding.py +++ b/tests/unit/fast_agent/agents/test_agent_history_binding.py @@ -1,7 +1,7 @@ from typing import cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent diff --git a/tests/unit/fast_agent/agents/test_agent_types.py b/tests/unit/fast_agent/agents/test_agent_types.py index f8fff31a0..f2dfd85fa 100644 --- a/tests/unit/fast_agent/agents/test_agent_types.py +++ b/tests/unit/fast_agent/agents/test_agent_types.py @@ -48,10 +48,10 @@ def test_instruction_propagates_to_default_request_params(): a user provides their own default_request_params. """ # Create RequestParams with custom settings but no systemPrompt - request_params = RequestParams(model="sonnet", temperature=0.7, maxTokens=32768) + request_params = RequestParams(model="sonnet", temperature=0.7, max_tokens=32768) # Verify systemPrompt is not set initially - assert request_params.systemPrompt is None + assert request_params.system_prompt is None # Create AgentConfig with both instruction and default_request_params instruction = "You are a helpful assistant specialized in testing." @@ -64,9 +64,9 @@ def test_instruction_propagates_to_default_request_params(): # The instruction should be propagated to default_request_params.systemPrompt assert config.default_request_params is not None - assert config.default_request_params.systemPrompt == instruction, ( + assert config.default_request_params.system_prompt == instruction, ( f"Expected systemPrompt to be '{instruction}', " - f"but got {config.default_request_params.systemPrompt}" + f"but got {config.default_request_params.system_prompt}" ) @@ -193,11 +193,11 @@ def test_instruction_takes_precedence_over_systemPrompt(): # Create RequestParams with a systemPrompt already set original_system_prompt = "You are a generic assistant from RequestParams." request_params = RequestParams( - model="sonnet", temperature=0.7, maxTokens=32768, systemPrompt=original_system_prompt + model="sonnet", temperature=0.7, max_tokens=32768, system_prompt=original_system_prompt ) # Verify systemPrompt is set initially - assert request_params.systemPrompt == original_system_prompt + assert request_params.system_prompt == original_system_prompt # Create AgentConfig with BOTH instruction AND default_request_params with systemPrompt instruction = "You are a specialized assistant from AgentConfig instruction." @@ -210,10 +210,10 @@ def test_instruction_takes_precedence_over_systemPrompt(): # The AgentConfig.instruction should take precedence over systemPrompt in RequestParams assert config.default_request_params is not None - assert config.default_request_params.systemPrompt == instruction, ( + assert config.default_request_params.system_prompt == instruction, ( f"Expected AgentConfig.instruction ('{instruction}') to override " f"RequestParams.systemPrompt ('{original_system_prompt}'), " - f"but got {config.default_request_params.systemPrompt}" + f"but got {config.default_request_params.system_prompt}" ) diff --git a/tests/unit/fast_agent/agents/test_llm_agent_streaming_handoff.py b/tests/unit/fast_agent/agents/test_llm_agent_streaming_handoff.py index 5a957789c..64ac61175 100644 --- a/tests/unit/fast_agent/agents/test_llm_agent_streaming_handoff.py +++ b/tests/unit/fast_agent/agents/test_llm_agent_streaming_handoff.py @@ -4,7 +4,7 @@ from typing import Any, Iterator, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent diff --git a/tests/unit/fast_agent/agents/test_llm_agent_web_metadata.py b/tests/unit/fast_agent/agents/test_llm_agent_web_metadata.py index 4cda1f0fa..9959a8009 100644 --- a/tests/unit/fast_agent/agents/test_llm_agent_web_metadata.py +++ b/tests/unit/fast_agent/agents/test_llm_agent_web_metadata.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from rich.text import Text from fast_agent.agents.agent_types import AgentConfig diff --git a/tests/unit/fast_agent/agents/test_llm_content_filter.py b/tests/unit/fast_agent/agents/test_llm_content_filter.py index 7419b0bbe..a7bf85c39 100644 --- a/tests/unit/fast_agent/agents/test_llm_content_filter.py +++ b/tests/unit/fast_agent/agents/test_llm_content_filter.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolResult, EmbeddedResource, @@ -10,7 +10,6 @@ ResourceLink, TextContent, ) -from pydantic import AnyUrl from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_decorator import LlmDecorator @@ -213,7 +212,7 @@ async def test_sanitizes_image_content_for_text_only_model(): decorator, stub = make_decorator("passthrough") text_block = TextContent(type="text", text="Hello") - image_block = ImageContent(type="image", data="AAA", mimeType="image/png") + image_block = ImageContent(type="image", data="AAA", mime_type="image/png") message = PromptMessageExtended(role="user", content=[text_block, image_block]) _, summary = decorator._sanitize_messages_for_llm([message]) @@ -252,7 +251,7 @@ async def test_sanitizes_image_content_for_text_only_model(): async def test_overlay_model_info_keeps_supported_image_content(): decorator, stub = make_overlay_vision_decorator() - image_block = ImageContent(type="image", data="AAA", mimeType="image/png") + image_block = ImageContent(type="image", data="AAA", mime_type="image/png") message = PromptMessageExtended(role="user", content=[image_block]) _, summary = decorator._sanitize_messages_for_llm([message]) @@ -272,12 +271,12 @@ async def test_removes_unsupported_tool_result_content(): decorator, stub = make_decorator("passthrough") resource = BlobResourceContents( - uri=AnyUrl("file://example.pdf"), - mimeType="application/pdf", + uri="file://example.pdf", + mime_type="application/pdf", blob="AA==", ) embedded = EmbeddedResource(type="resource", resource=resource) - tool_result = CallToolResult(content=[embedded], isError=False) + tool_result = CallToolResult(content=[embedded], is_error=False) message = PromptMessageExtended(role="user", tool_results={"tool1": tool_result}) _, summary = decorator._sanitize_messages_for_llm([message]) @@ -318,8 +317,8 @@ async def test_removes_remote_office_document_links_for_anthropic() -> None: resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/report.docx"), - mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri="https://example.com/report.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", name="report.docx", ) message = PromptMessageExtended(role="user", content=[resource]) @@ -341,7 +340,7 @@ async def test_removes_remote_office_document_links_for_anthropic() -> None: async def test_metadata_clears_when_supported_content_only(): decorator, stub = make_decorator("passthrough") - image_block = ImageContent(type="image", data="AAA", mimeType="image/png") + image_block = ImageContent(type="image", data="AAA", mime_type="image/png") first_message = PromptMessageExtended(role="user", content=[image_block]) await decorator.generate_impl([first_message]) @@ -362,7 +361,7 @@ async def test_metadata_clears_when_supported_content_only(): async def test_history_persists_alert_channel_but_strips_removed_metadata(): decorator, _ = make_decorator("passthrough") - image_block = ImageContent(type="image", data="AAA", mimeType="image/png") + image_block = ImageContent(type="image", data="AAA", mime_type="image/png") message = PromptMessageExtended(role="user", content=[image_block]) await decorator.generate_impl([message]) diff --git a/tests/unit/fast_agent/agents/test_mcp_agent_local_tools.py b/tests/unit/fast_agent/agents/test_mcp_agent_local_tools.py index bede4d133..873eb4365 100644 --- a/tests/unit/fast_agent/agents/test_mcp_agent_local_tools.py +++ b/tests/unit/fast_agent/agents/test_mcp_agent_local_tools.py @@ -6,7 +6,7 @@ import pytest from fastmcp.tools import FunctionTool, ToolResult -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, CallToolResult, @@ -26,6 +26,10 @@ from fast_agent.llm.request_params import RequestParams from fast_agent.llm.terminal_output_limits import calculate_terminal_output_limit_for_model from fast_agent.mcp.mcp_aggregator import NamespacedTool +from fast_agent.mcp.tool_result_metadata import ( + tool_result_display_metadata, + update_tool_result_display_metadata, +) from fast_agent.skills.registry import SkillRegistry from fast_agent.tools.skill_reader import READ_SKILL_TOOL_NAME from fast_agent.types import PromptMessageExtended @@ -178,14 +182,14 @@ def __init__(self) -> None: assert "sample_tool" in tool_names result: CallToolResult = await agent.call_tool("sample_tool", {"video_id": "1234"}) - assert not result.isError + assert not result.is_error assert calls == [{"video_id": "1234"}] assert result.content is not None assert len(result.content) == 1 assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) assert result.content[0].text == "transcript for 1234" - assert result.structuredContent is None + assert result.structured_content is None await agent._aggregator.close() @@ -204,8 +208,8 @@ def summarize() -> dict[str, str]: result = await agent.call_tool("summarize", {}) - assert result.isError is False - assert result.structuredContent is None + assert result.is_error is False + assert result.structured_content is None assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == '{"status":"ok"}' @@ -227,8 +231,8 @@ def add(a: int, b: int) -> int: result = await agent.call_tool("add", {"a": 2, "b": 3}) - assert result.isError is False - assert result.structuredContent == {"result": 5} + assert result.is_error is False + assert result.structured_content == {"result": 5} await agent._aggregator.close() @@ -250,8 +254,8 @@ def summarize() -> ToolResult: result = await agent.call_tool("summarize", {}) - assert result.isError is False - assert result.structuredContent == {"status": "ok"} + assert result.is_error is False + assert result.structured_content == {"status": "ok"} assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == '{"status":"ok"}' @@ -472,7 +476,7 @@ async def test_shell_can_include_local_read_text_file_when_enabled(tmp_path: Pat "read_text_file", {"path": str(test_file), "line": 2, "limit": 1}, ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "two" @@ -497,7 +501,7 @@ async def test_shell_can_include_local_write_text_file_when_enabled(tmp_path: Pa "write_text_file", {"path": str(output_file), "content": "hello from write tool"}, ) - assert result.isError is False + assert result.is_error is False assert output_file.read_text(encoding="utf-8") == "hello from write tool" assert result.content is not None assert isinstance(result.content[0], TextContent) @@ -535,10 +539,10 @@ async def test_shell_can_call_edit_file_when_local_filesystem_runtime_is_enabled }, ) - assert result.isError is False + assert result.is_error is False assert target_file.read_text(encoding="utf-8") == "hello there\n" - assert result.structuredContent is not None - assert result.structuredContent["success"] is True + assert result.structured_content is not None + assert result.structured_content["success"] is True await agent._aggregator.close() @@ -646,8 +650,8 @@ async def ensure_tool_call_exists( request_params=params, ) - assert edit_result.isError is False - assert patch_result.isError is False + assert edit_result.is_error is False + assert patch_result.is_error is False assert handler.starts == [ ( "edit_file", @@ -706,7 +710,7 @@ async def test_shell_can_include_apply_patch_when_model_prefers_it(tmp_path: Pat ) result = await agent.call_tool("apply_patch", {"input": patch_text}) - assert result.isError is False + assert result.is_error is False assert target_file.read_text(encoding="utf-8") == "ONE\ntwo\n" assert result.content is not None assert isinstance(result.content[0], TextContent) @@ -1071,7 +1075,7 @@ def __init__(self) -> None: Tool( name="read_text_file", description="ACP read tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"path": {"type": "string"}}, }, @@ -1079,7 +1083,7 @@ def __init__(self) -> None: Tool( name="write_text_file", description="ACP write tool", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": {"type": "string"}, @@ -1095,7 +1099,7 @@ async def read_text_file( tool_use_id: str | None = None, ) -> CallToolResult: del arguments, tool_use_id - return CallToolResult(content=[TextContent(type="text", text="acp")], isError=False) + return CallToolResult(content=[TextContent(type="text", text="acp")], is_error=False) async def write_text_file( self, @@ -1106,7 +1110,7 @@ async def write_text_file( assert arguments is not None return CallToolResult( content=[TextContent(type="text", text=f"acp-write:{arguments['path']}")], - isError=False, + is_error=False, ) def metadata(self) -> dict[str, object]: @@ -1127,7 +1131,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[TextContent(type="text", text=f"unsupported: {name}")], - isError=True, + is_error=True, ) config = AgentConfig(name="test", instruction="Instruction", servers=[], shell=True) @@ -1147,7 +1151,7 @@ async def call_tool( assert "edit_file" in replaced_tool_names result = await agent.call_tool("read_text_file", {"path": "/tmp/anything"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "acp" @@ -1156,7 +1160,7 @@ async def call_tool( "write_text_file", {"path": "/tmp/output.txt", "content": "ignored by acp stub"}, ) - assert write_result.isError is False + assert write_result.is_error is False assert write_result.content is not None assert isinstance(write_result.content[0], TextContent) assert write_result.content[0].text == "acp-write:/tmp/output.txt" @@ -1172,7 +1176,7 @@ async def call_tool( "new_string": "there", }, ) - assert edit_result.isError is False + assert edit_result.is_error is False assert edit_target.read_text(encoding="utf-8") == "hello there\n" finally: edit_target.unlink(missing_ok=True) @@ -1190,12 +1194,12 @@ def __init__(self) -> None: Tool( name="read_text_file", description="ACP read tool", - inputSchema={"type": "object", "properties": {"path": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, ), Tool( name="write_text_file", description="ACP write tool", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": {"type": "string"}, @@ -1212,7 +1216,7 @@ async def read_text_file( ) -> CallToolResult: del arguments, tool_use_id return CallToolResult( - content=[TextContent(type="text", text="acp-read")], isError=False + content=[TextContent(type="text", text="acp-read")], is_error=False ) async def write_text_file( @@ -1222,7 +1226,7 @@ async def write_text_file( ) -> CallToolResult: del arguments, tool_use_id return CallToolResult( - content=[TextContent(type="text", text="acp-write")], isError=False + content=[TextContent(type="text", text="acp-write")], is_error=False ) def metadata(self) -> dict[str, object]: @@ -1243,7 +1247,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[TextContent(type="text", text=f"unsupported: {name}")], - isError=True, + is_error=True, ) target_file = tmp_path / "notes.txt" @@ -1270,7 +1274,7 @@ async def call_tool( ) result = await agent.call_tool("apply_patch", {"input": patch_text}) - assert result.isError is False + assert result.is_error is False assert target_file.read_text(encoding="utf-8") == "ONE\ntwo\n" await agent._aggregator.close() @@ -1284,7 +1288,7 @@ def __init__(self) -> None: Tool( name="read_text_file", description="Local read tool", - inputSchema={"type": "object", "properties": {"path": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, ) ] self.read_calls: list[dict[str, object] | None] = [] @@ -1296,7 +1300,7 @@ async def read_text_file( ) -> CallToolResult: del tool_use_id self.read_calls.append(arguments) - return CallToolResult(content=[TextContent(type="text", text="local")], isError=False) + return CallToolResult(content=[TextContent(type="text", text="local")], is_error=False) async def write_text_file( self, @@ -1306,7 +1310,7 @@ async def write_text_file( del arguments, tool_use_id return CallToolResult( content=[TextContent(type="text", text="write unsupported")], - isError=True, + is_error=True, ) def metadata(self) -> dict[str, object]: @@ -1327,7 +1331,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[TextContent(type="text", text=f"unsupported: {name}")], - isError=True, + is_error=True, ) config = AgentConfig(name="test", instruction="Instruction", servers=[], shell=False) @@ -1337,7 +1341,7 @@ async def call_tool( mcp_tool = Tool( name="read_text_file", description="MCP read tool", - inputSchema={"type": "object", "properties": {"path": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, ) namespaced_tool = NamespacedTool( tool=mcp_tool, @@ -1367,7 +1371,7 @@ async def fake_call_tool( ) -> CallToolResult: del arguments, tool_use_id, request_tool_handler mcp_calls.append(name) - return CallToolResult(content=[TextContent(type="text", text="mcp")], isError=False) + return CallToolResult(content=[TextContent(type="text", text="mcp")], is_error=False) async def fake_get_skybridge_config(server_name: str) -> None: del server_name @@ -1414,7 +1418,7 @@ def __init__(self) -> None: Tool( name="write_text_file", description="Local write tool", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": {"type": "string"}, @@ -1433,7 +1437,7 @@ async def read_text_file( del arguments, tool_use_id return CallToolResult( content=[TextContent(type="text", text="read unsupported")], - isError=True, + is_error=True, ) async def write_text_file( @@ -1443,7 +1447,7 @@ async def write_text_file( ) -> CallToolResult: del tool_use_id self.write_calls.append(arguments) - return CallToolResult(content=[TextContent(type="text", text="local")], isError=False) + return CallToolResult(content=[TextContent(type="text", text="local")], is_error=False) def metadata(self) -> dict[str, object]: return {"variant": "local_filesystem"} @@ -1463,7 +1467,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[TextContent(type="text", text=f"unsupported: {name}")], - isError=True, + is_error=True, ) config = AgentConfig(name="test", instruction="Instruction", servers=[], shell=False) @@ -1473,7 +1477,7 @@ async def call_tool( mcp_tool = Tool( name="write_text_file", description="MCP write tool", - inputSchema={ + input_schema={ "type": "object", "properties": { "path": {"type": "string"}, @@ -1509,7 +1513,7 @@ async def fake_call_tool( ) -> CallToolResult: del arguments, tool_use_id, request_tool_handler mcp_calls.append(name) - return CallToolResult(content=[TextContent(type="text", text="mcp")], isError=False) + return CallToolResult(content=[TextContent(type="text", text="mcp")], is_error=False) async def fake_get_skybridge_config(server_name: str) -> None: del server_name @@ -1647,7 +1651,7 @@ async def fake_call_tool( request_params: RequestParams | None = None, ) -> CallToolResult: del name, arguments, tool_use_id, request_tool_handler, request_params - return CallToolResult(content=[TextContent(type="text", text=output)], isError=False) + return CallToolResult(content=[TextContent(type="text", text=output)], is_error=False) agent.call_tool = cast("Any", fake_call_tool) agent._model_tool_output_byte_limit = cast("Any", lambda _llm=None: 40) @@ -1715,7 +1719,7 @@ def __init__(self) -> None: self.tool = Tool( name="execute", description="Run shell command", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) self.calls: list[dict[str, object]] = [] self.tools = [self.tool] @@ -1747,7 +1751,7 @@ async def execute( "defer_display_to_tool_result": defer_display_to_tool_result, } ) - return CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + return CallToolResult(content=[TextContent(type="text", text="ok")], is_error=False) async def call_tool( self, @@ -1792,7 +1796,7 @@ def __init__(self) -> None: self.tool = Tool( name="execute", description="Run shell command", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) self.tools = [self.tool] @@ -1823,9 +1827,12 @@ async def execute( result = CallToolResult( content=[TextContent(type="text", text=f"{command}\nprocess exit code was 0")], - isError=False, + is_error=False, + ) + update_tool_result_display_metadata( + result, + {"suppress_display": not defer_display_to_tool_result}, ) - setattr(result, "_suppress_display", not defer_display_to_tool_result) return result async def call_tool( @@ -1901,7 +1908,7 @@ def __init__(self) -> None: Tool( name="read_text_file", description="Read file", - inputSchema={"type": "object", "properties": {"path": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, ) ] @@ -1913,7 +1920,7 @@ async def read_text_file( del arguments, tool_use_id return CallToolResult( content=[TextContent(type="text", text="line-1\nline-2")], - isError=False, + is_error=False, ) async def write_text_file( @@ -1924,7 +1931,7 @@ async def write_text_file( del arguments, tool_use_id return CallToolResult( content=[TextContent(type="text", text="unsupported")], - isError=True, + is_error=True, ) def metadata(self) -> dict[str, object]: @@ -1945,7 +1952,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[TextContent(type="text", text=f"unsupported: {name}")], - isError=True, + is_error=True, ) class RecordingDisplay: @@ -1996,7 +2003,7 @@ def __init__(self) -> None: Tool( name="read_text_file", description="Read file", - inputSchema={"type": "object", "properties": {"path": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, ) ] @@ -2008,7 +2015,7 @@ async def read_text_file( del arguments, tool_use_id return CallToolResult( content=[TextContent(type="text", text="line-1\nline-2")], - isError=False, + is_error=False, ) async def write_text_file( @@ -2019,7 +2026,7 @@ async def write_text_file( del arguments, tool_use_id return CallToolResult( content=[TextContent(type="text", text="unsupported")], - isError=True, + is_error=True, ) def metadata(self) -> dict[str, object]: @@ -2040,7 +2047,7 @@ async def call_tool( return await self.write_text_file(arguments, tool_use_id) return CallToolResult( content=[TextContent(type="text", text=f"unsupported: {name}")], - isError=True, + is_error=True, ) class RecordingDisplay: @@ -2088,13 +2095,15 @@ def show_tool_result(self, *args: object, **kwargs: object) -> None: assert recording_display.result_type_labels == ["file read", "file read"] assert recording_display.result_tool_call_ids == [None, None] assert [ - getattr(result, "read_text_file_line", None) for result in recording_display.results + tool_result_display_metadata(result).get("read_text_file_line") + for result in recording_display.results ] == [ 1, 1, ] assert [ - getattr(result, "read_text_file_limit", None) for result in recording_display.results + tool_result_display_metadata(result).get("read_text_file_limit") + for result in recording_display.results ] == [ 20, 20, diff --git a/tests/unit/fast_agent/agents/test_mcp_agent_skills.py b/tests/unit/fast_agent/agents/test_mcp_agent_skills.py index 728327067..811b1d91f 100644 --- a/tests/unit/fast_agent/agents/test_mcp_agent_skills.py +++ b/tests/unit/fast_agent/agents/test_mcp_agent_skills.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.mcp_agent import McpAgent @@ -192,7 +192,7 @@ async def test_skill_reader_rejects_relative_path(tmp_path: Path) -> None: result = await reader.execute({"path": "skills/alpha/SKILL.md"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) @@ -209,7 +209,7 @@ async def test_skill_reader_rejects_non_dict_arguments(tmp_path: Path) -> None: result = await reader.execute("not a mapping") # ty: ignore[invalid-argument-type] - assert result.isError is True + assert result.is_error is True assert result.content is not None assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) @@ -230,7 +230,7 @@ async def test_skill_reader_blocks_outside_skill_directory(tmp_path: Path) -> No result = await reader.execute({"path": str(outside_file)}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) @@ -249,7 +249,7 @@ async def test_skill_reader_reads_valid_skill_file(tmp_path: Path) -> None: skill_file = skills_root / "alpha" / "SKILL.md" result = await reader.execute({"path": str(skill_file)}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert any( isinstance(block, TextContent) and "Alpha body" in block.text for block in result.content diff --git a/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py b/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py index ed189f372..b0d37d133 100644 --- a/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py +++ b/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py @@ -1,6 +1,6 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, Tool +from mcp_types import CallToolRequestParams, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent diff --git a/tests/unit/fast_agent/agents/test_tool_agent_unanswered_tool_call_logging.py b/tests/unit/fast_agent/agents/test_tool_agent_unanswered_tool_call_logging.py index 76fe97b76..2b2cf421c 100644 --- a/tests/unit/fast_agent/agents/test_tool_agent_unanswered_tool_call_logging.py +++ b/tests/unit/fast_agent/agents/test_tool_agent_unanswered_tool_call_logging.py @@ -1,6 +1,6 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequestParams, CallToolResult, TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent diff --git a/tests/unit/fast_agent/agents/test_tool_runner_cancelled_history.py b/tests/unit/fast_agent/agents/test_tool_runner_cancelled_history.py index 026f0c3d1..58b4ebac6 100644 --- a/tests/unit/fast_agent/agents/test_tool_runner_cancelled_history.py +++ b/tests/unit/fast_agent/agents/test_tool_runner_cancelled_history.py @@ -3,7 +3,7 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, CallToolResult, TextContent, Tool +from mcp_types import CallToolRequestParams, CallToolResult, TextContent, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent @@ -306,7 +306,7 @@ async def test_external_cancel_after_completed_tool_preserves_real_result() -> N [content] = result.content assert isinstance(content, TextContent) assert content.text == "ok" - assert result.isError in (False, None) + assert result.is_error in (False, None) rollback_state = agent.last_turn_history_state assert rollback_state is not None @@ -454,7 +454,7 @@ async def test_generate_continues_from_staged_tool_result_history() -> None: resumed_results = history[2].tool_results assert resumed_results is not None assert "call-1" in resumed_results - assert resumed_results["call-1"].isError in (False, None) + assert resumed_results["call-1"].is_error in (False, None) assert history[2].last_text() == "ok" diff --git a/tests/unit/fast_agent/agents/test_tool_runner_hooks.py b/tests/unit/fast_agent/agents/test_tool_runner_hooks.py index 6a8d9056e..3d70f9b9b 100644 --- a/tests/unit/fast_agent/agents/test_tool_runner_hooks.py +++ b/tests/unit/fast_agent/agents/test_tool_runner_hooks.py @@ -1,6 +1,6 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, ContentBlock, ImageContent, Tool +from mcp_types import CallToolRequestParams, ContentBlock, ImageContent, Tool from rich.text import Text from fast_agent.agents.agent_types import AgentConfig @@ -157,7 +157,7 @@ async def _apply_prompt_provider_specific( class MediaStagingToolAgent(ToolAgent): def _consume_pending_media_attachments(self) -> list[ContentBlock]: - return [ImageContent(type="image", data="abcd", mimeType="image/png")] + return [ImageContent(type="image", data="abcd", mime_type="image/png")] @pytest.mark.unit diff --git a/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py b/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py index 7660cb6f2..c6ab89c2d 100644 --- a/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py +++ b/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py @@ -1,6 +1,6 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, CallToolResult, Tool +from mcp_types import CallToolRequestParams, CallToolResult, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent @@ -188,9 +188,9 @@ async def test_passthrough_uses_structured_content_for_tool_result_text() -> Non llm = PassthroughLLM() tool_result = CallToolResult( content=[text_content("stale summary")], - isError=False, + structured_content={"b": 2, "a": 1}, + is_error=False, ) - setattr(tool_result, "structuredContent", {"b": 2, "a": 1}) message = PromptMessageExtended(role="user", content=[], tool_results={"call_1": tool_result}) result = await llm._apply_prompt_provider_specific([message]) diff --git a/tests/unit/fast_agent/agents/workflow/test_agents_as_tools_agent.py b/tests/unit/fast_agent/agents/workflow/test_agents_as_tools_agent.py index f67901f33..1f3a4b4d2 100644 --- a/tests/unit/fast_agent/agents/workflow/test_agents_as_tools_agent.py +++ b/tests/unit/fast_agent/agents/workflow/test_agents_as_tools_agent.py @@ -7,7 +7,7 @@ import pytest import pytest_asyncio from mcp import CallToolRequest, Tool -from mcp.types import CallToolRequestParams, CallToolResult, PromptMessage, TextContent +from mcp_types import CallToolRequestParams, CallToolResult, PromptMessage, TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent @@ -318,7 +318,7 @@ async def test_list_tools_merges_base_and_child(): await agent.initialize() # Inject a base MCP tool via the filtered MCP path to ensure merge behavior. - base_tool = Tool(name="base_tool", description="base", inputSchema={"type": "object"}) + base_tool = Tool(name="base_tool", description="base", input_schema={"type": "object"}) setattr(agent, "_get_filtered_mcp_tools", AsyncMock(return_value=[base_tool])) result = await agent.list_tools() @@ -346,7 +346,7 @@ async def test_call_tool_routes_collision_to_advertised_base_tool(monkeypatch): agent = AgentsAsToolsAgent(AgentConfig("parent"), [child]) await agent.initialize() - base_tool = Tool(name="agent__child", description="base", inputSchema={"type": "object"}) + base_tool = Tool(name="agent__child", description="base", input_schema={"type": "object"}) setattr(agent, "_get_filtered_mcp_tools", AsyncMock(return_value=[base_tool])) async def fake_base_call_tool( @@ -358,13 +358,13 @@ async def fake_base_call_tool( request_params: RequestParams | None = None, ) -> CallToolResult: del self, arguments, tool_use_id, request_params - return CallToolResult(content=[text_content(f"base:{name}")], isError=False) + return CallToolResult(content=[text_content(f"base:{name}")], is_error=False) monkeypatch.setattr(McpAgent, "call_tool", fake_base_call_tool) result = await agent.call_tool("agent__child", {"message": "hi"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert get_text(result.content[0]) == "base:agent__child" assert child.last_input_text is None @@ -376,7 +376,7 @@ async def test_run_tools_routes_collision_to_advertised_base_tool(monkeypatch): agent = AgentsAsToolsAgent(AgentConfig("parent"), [child]) await agent.initialize() - base_tool = Tool(name="agent__child", description="base", inputSchema={"type": "object"}) + base_tool = Tool(name="agent__child", description="base", input_schema={"type": "object"}) setattr(agent, "_get_filtered_mcp_tools", AsyncMock(return_value=[base_tool])) async def fake_base_run_tools( @@ -390,7 +390,7 @@ async def fake_base_run_tools( tool_results={ cid: CallToolResult( content=[text_content(f"base:{tool.params.name}")], - isError=False, + is_error=False, ) for cid, tool in (request.tool_calls or {}).items() }, @@ -433,7 +433,7 @@ async def test_list_tools_uses_child_tool_input_schema(): result = await agent.list_tools() child_tool = next(tool for tool in result.tools if tool.name == "agent__child") - assert child_tool.inputSchema == child.config.tool_input_schema + assert child_tool.input_schema == child.config.tool_input_schema @pytest.mark.asyncio @@ -446,9 +446,9 @@ async def test_list_tools_adds_response_mode_when_child_tool_result_mode_is_sele result = await agent.list_tools() child_tool = next(tool for tool in result.tools if tool.name == "agent__child") - properties = child_tool.inputSchema.get("properties", {}) + properties = child_tool.input_schema.get("properties", {}) - assert child_tool.inputSchema.get("required") == ["message"] + assert child_tool.input_schema.get("required") == ["message"] assert properties.get("response_mode") == { "type": "string", "description": "Override how the child agent returns tool results for this call.", @@ -482,10 +482,10 @@ async def test_run_tools_respects_max_parallel_and_timeout(): fast_result = result_message.tool_results["1"] slow_result = result_message.tool_results["2"] - assert not fast_result.isError + assert not fast_result.is_error # max_parallel limits concurrency without dropping requested calls; the slow # call still runs and then hits the per-child timeout. - assert slow_result.isError + assert slow_result.is_error assert slow_result.content is not None assert slow_result.content[0].type == "text" assert isinstance(slow_result.content[0], TextContent) @@ -503,7 +503,7 @@ async def test_run_tools_respects_max_parallel_and_timeout(): single_result = await agent.run_tools(request_single) assert single_result.tool_results is not None err_res = single_result.tool_results["3"] - assert err_res.isError + assert err_res.is_error assert err_res.content is not None assert any( isinstance(block, TextContent) and "Tool execution failed" in (block.text or "") @@ -531,7 +531,7 @@ async def fake_base_run_tools( tool_results={ correlation_id: CallToolResult( content=[text_content(f"mcp:{correlation_id}")], - isError=False, + is_error=False, ) for correlation_id in request.tool_calls }, @@ -586,7 +586,7 @@ async def test_invoke_child_uses_structured_json_input_for_custom_schema(): {"query": "find updates", "sources": ["docs.fast-agent.ai"]}, ) - assert result.isError is False + assert result.is_error is False assert child.last_input_text is not None assert json.loads(child.last_input_text) == { "query": "find updates", @@ -624,7 +624,7 @@ async def test_invoke_child_uses_structured_json_input_for_mixed_message_schema( {"message": "context", "query": "find updates", "filters": ["docs", "code"]}, ) - assert result.isError is False + assert result.is_error is False assert child.last_input_text is not None assert json.loads(child.last_input_text) == { "message": "context", @@ -652,7 +652,7 @@ async def test_invoke_child_uses_legacy_message_input_for_message_only_schema(): result = await agent._invoke_child_agent(child, {"message": "hello child"}) - assert result.isError is False + assert result.is_error is False assert child.last_input_text == "hello child" @@ -664,7 +664,7 @@ async def test_child_delegation_keeps_workflow_settings_without_parent_llm_defau parent_request_params = RequestParams( model="parent-model", - maxTokens=123, + max_tokens=123, tool_result_mode="passthrough", ) await agent._invoke_child_agent( @@ -675,7 +675,7 @@ async def test_child_delegation_keeps_workflow_settings_without_parent_llm_defau assert child.last_request_params is not None assert child.last_request_params.model is None - assert "maxTokens" not in child.last_request_params.model_dump(exclude_unset=True) + assert "max_tokens" not in child.last_request_params.model_dump(exclude_unset=True) assert child.last_request_params.tool_result_mode == "passthrough" @@ -699,7 +699,7 @@ async def test_child_response_mode_overrides_inherited_passthrough_and_is_stripp parent_request_params = RequestParams( model="parent-model", - maxTokens=123, + max_tokens=123, tool_result_mode="passthrough", ) await agent._invoke_child_agent( @@ -713,7 +713,7 @@ async def test_child_response_mode_overrides_inherited_passthrough_and_is_stripp assert child.last_request_params is not None assert child.last_request_params.model is None - assert "maxTokens" not in child.last_request_params.model_dump(exclude_unset=True) + assert "max_tokens" not in child.last_request_params.model_dump(exclude_unset=True) assert child.last_request_params.tool_result_mode == "postprocess" assert child.last_input_text is not None assert json.loads(child.last_input_text) == {"query": "find updates"} @@ -742,7 +742,7 @@ async def test_child_response_mode_rejects_invalid_value_when_control_enabled() }, ) - assert result.isError is True + assert result.is_error is True assert result.content is not None error_text = get_text(result.content[0]) assert error_text is not None @@ -782,7 +782,7 @@ async def test_child_response_mode_field_is_preserved_when_control_disabled() -> assert child.last_request_params is not parent_request_params assert child.last_request_params is not None - assert child.last_request_params.systemPrompt is None + assert child.last_request_params.system_prompt is None assert child.last_request_params.model is None assert child.last_request_params.tool_result_mode == "passthrough" assert child.last_input_text is not None @@ -817,7 +817,7 @@ async def test_child_owned_response_mode_field_is_preserved_when_selectable() -> listed_tools = await agent.list_tools() child_tool = next(tool for tool in listed_tools.tools if tool.name == "agent__child") - assert child_tool.inputSchema == child.config.tool_input_schema + assert child_tool.input_schema == child.config.tool_input_schema await agent._invoke_child_agent( child, @@ -990,7 +990,7 @@ def test_history_options_reject_unsupported_message_merge_target() -> None: def test_child_request_params_strip_parent_system_prompt() -> None: request_params = RequestParams( - systemPrompt="parent instruction", + system_prompt="parent instruction", model="passthrough", tool_result_mode="selectable", ) @@ -1001,14 +1001,14 @@ def test_child_request_params_strip_parent_system_prompt() -> None: ) assert child_params is not None - assert child_params.systemPrompt is None + assert child_params.system_prompt is None assert child_params.model is None assert child_params.tool_result_mode == "selectable" def test_child_request_params_strip_parent_system_prompt_with_response_mode_override() -> None: request_params = RequestParams( - systemPrompt="parent instruction", + system_prompt="parent instruction", model="passthrough", tool_result_mode="selectable", ) @@ -1019,14 +1019,14 @@ def test_child_request_params_strip_parent_system_prompt_with_response_mode_over ) assert child_params is not None - assert child_params.systemPrompt is None + assert child_params.system_prompt is None assert child_params.model is None assert child_params.tool_result_mode == "passthrough" def test_child_request_params_preserve_explicit_no_history() -> None: request_params = RequestParams( - systemPrompt="parent instruction", + system_prompt="parent instruction", model="passthrough", use_history=False, ) @@ -1037,7 +1037,7 @@ def test_child_request_params_preserve_explicit_no_history() -> None: ) assert child_params is not None - assert child_params.systemPrompt is None + assert child_params.system_prompt is None assert child_params.model is None assert child_params.use_history is False @@ -1050,7 +1050,7 @@ async def test_invoke_child_appends_error_channel(): call_result = await agent._invoke_child_agent(child, {"text": "hi"}) - assert call_result.isError + assert call_result.is_error assert call_result.content is not None texts = [block.text for block in call_result.content if isinstance(block, TextContent)] assert "err-block" in texts @@ -1075,7 +1075,7 @@ async def test_nested_agents_as_tools_preserves_instance_labels(): result_message = await parent.run_tools(request) assert result_message.tool_results is not None result = result_message.tool_results["1"] - assert not result.isError + assert not result.is_error # Reply should include the instance-suffixed nested agent name. assert result.content is not None assert any( diff --git a/tests/unit/fast_agent/agents/workflow/test_chain_agent.py b/tests/unit/fast_agent/agents/workflow/test_chain_agent.py index 119943913..df1800302 100644 --- a/tests/unit/fast_agent/agents/workflow/test_chain_agent.py +++ b/tests/unit/fast_agent/agents/workflow/test_chain_agent.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from mcp.types import ImageContent +from mcp_types import ImageContent from pydantic import BaseModel from fast_agent.agents.agent_types import AgentConfig @@ -17,7 +17,7 @@ from collections.abc import Sequence from mcp import Tool - from mcp.types import PromptMessage + from mcp_types import PromptMessage class StructuredResult(BaseModel): @@ -127,7 +127,7 @@ async def generate( role="assistant", content=[ text_content(self.response_text), - ImageContent(type="image", data="aW1hZ2U=", mimeType="image/png"), + ImageContent(type="image", data="aW1hZ2U=", mime_type="image/png"), ], ) diff --git a/tests/unit/fast_agent/agents/workflow/test_iterative_planner.py b/tests/unit/fast_agent/agents/workflow/test_iterative_planner.py index 8b7ab6dbf..34c151f45 100644 --- a/tests/unit/fast_agent/agents/workflow/test_iterative_planner.py +++ b/tests/unit/fast_agent/agents/workflow/test_iterative_planner.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent diff --git a/tests/unit/fast_agent/agents/workflow/test_router_unit.py b/tests/unit/fast_agent/agents/workflow/test_router_unit.py index 5bf707e4f..fa35b85c2 100644 --- a/tests/unit/fast_agent/agents/workflow/test_router_unit.py +++ b/tests/unit/fast_agent/agents/workflow/test_router_unit.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from collections.abc import Sequence - from mcp.types import PromptMessage + from mcp_types import PromptMessage class RecordingSchemaAgent(LlmAgent): diff --git a/tests/unit/fast_agent/agents/workflow/test_workflow_request_params.py b/tests/unit/fast_agent/agents/workflow/test_workflow_request_params.py index 7be653a58..60b9e41cc 100644 --- a/tests/unit/fast_agent/agents/workflow/test_workflow_request_params.py +++ b/tests/unit/fast_agent/agents/workflow/test_workflow_request_params.py @@ -25,7 +25,7 @@ from collections.abc import Sequence from mcp import Tool - from mcp.types import PromptMessage + from mcp_types import PromptMessage class StructuredResult(BaseModel): @@ -103,8 +103,8 @@ async def structured_schema( def _parent_params() -> RequestParams: return RequestParams( model="parent-model", - systemPrompt="parent instructions", - maxTokens=17, + system_prompt="parent instructions", + max_tokens=17, use_history=False, ) @@ -112,8 +112,8 @@ def _parent_params() -> RequestParams: def _assert_child_params(params: RequestParams | None) -> None: assert params is not None assert params.model is None - assert params.systemPrompt is None - assert params.maxTokens is None + assert params.system_prompt is None + assert params.max_tokens is None assert params.use_history is False diff --git a/tests/unit/fast_agent/cli/test_session_resume.py b/tests/unit/fast_agent/cli/test_session_resume.py index 2b971bb54..d1dd28fa3 100644 --- a/tests/unit/fast_agent/cli/test_session_resume.py +++ b/tests/unit/fast_agent/cli/test_session_resume.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal, cast -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.cli.runtime.session_resume import emit_resume_assistant_preview from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/unit/fast_agent/commands/test_export_command.py b/tests/unit/fast_agent/commands/test_export_command.py index f67cdaf15..66abd2d83 100644 --- a/tests/unit/fast_agent/commands/test_export_command.py +++ b/tests/unit/fast_agent/commands/test_export_command.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from click.utils import strip_ansi -from mcp.types import TextContent +from mcp_types import TextContent from typer.testing import CliRunner import fast_agent.cli.commands.export as export_command diff --git a/tests/unit/fast_agent/commands/test_history_summaries.py b/tests/unit/fast_agent/commands/test_history_summaries.py index c77d3b9c0..f3915e9f0 100644 --- a/tests/unit/fast_agent/commands/test_history_summaries.py +++ b/tests/unit/fast_agent/commands/test_history_summaries.py @@ -1,6 +1,6 @@ import json -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from fast_agent.commands.history_summaries import build_history_turn_report from fast_agent.constants import ( @@ -88,7 +88,7 @@ def test_build_history_turn_report_calculates_turn_metrics() -> None: tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="result")], - isError=False, + is_error=False, ) }, channels={ diff --git a/tests/unit/fast_agent/commands/test_runtime_result_export.py b/tests/unit/fast_agent/commands/test_runtime_result_export.py index ad1c7dbdd..3ac852aa1 100644 --- a/tests/unit/fast_agent/commands/test_runtime_result_export.py +++ b/tests/unit/fast_agent/commands/test_runtime_result_export.py @@ -10,7 +10,7 @@ import pytest import typer from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, ListToolsResult, TextContent +from mcp_types import CallToolRequestParams, ListToolsResult, TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent diff --git a/tests/unit/fast_agent/commands/test_session_handlers_noenv.py b/tests/unit/fast_agent/commands/test_session_handlers_noenv.py index 2a5dd3755..25a2e5df8 100644 --- a/tests/unit/fast_agent/commands/test_session_handlers_noenv.py +++ b/tests/unit/fast_agent/commands/test_session_handlers_noenv.py @@ -7,7 +7,7 @@ from typing import Any, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.commands.context import CommandContext from fast_agent.commands.handlers import sessions as session_handlers diff --git a/tests/unit/fast_agent/commands/test_status_markdown.py b/tests/unit/fast_agent/commands/test_status_markdown.py index e545742e3..eb3248fdc 100644 --- a/tests/unit/fast_agent/commands/test_status_markdown.py +++ b/tests/unit/fast_agent/commands/test_status_markdown.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentType from fast_agent.commands.renderers import status_markdown as status_renderers diff --git a/tests/unit/fast_agent/commands/test_tool_summaries.py b/tests/unit/fast_agent/commands/test_tool_summaries.py index 29aab74e0..4431074fd 100644 --- a/tests/unit/fast_agent/commands/test_tool_summaries.py +++ b/tests/unit/fast_agent/commands/test_tool_summaries.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from mcp.types import Tool +from mcp_types import Tool from fast_agent.commands import tool_summaries from fast_agent.commands.tool_summaries import ( @@ -48,7 +48,7 @@ def _tool( title=None, description=description, _meta=meta or {}, - inputSchema=input_schema or {}, + input_schema=input_schema or {}, ) diff --git a/tests/unit/fast_agent/commands/test_tools_handler.py b/tests/unit/fast_agent/commands/test_tools_handler.py index dd2707554..5f4019940 100644 --- a/tests/unit/fast_agent/commands/test_tools_handler.py +++ b/tests/unit/fast_agent/commands/test_tools_handler.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import pytest -from mcp.types import ListToolsResult, Tool +from mcp_types import ListToolsResult, Tool from rich.text import Text from fast_agent.commands.context import CommandContext, NonInteractiveCommandIOBase @@ -217,7 +217,7 @@ async def test_tools_named_selection_renders_complete_input_schema() -> None: } agent = _Agent( _MutableLlm(), - [Tool(name="search", description="Search things", inputSchema=schema)], + [Tool(name="search", description="Search things", input_schema=schema)], ) ctx = CommandContext( agent_provider=_Provider(agent), @@ -241,7 +241,7 @@ async def test_tools_named_selection_renders_complete_input_schema() -> None: async def test_tools_summary_argument_keeps_existing_list_behavior() -> None: agent = _Agent( _MutableLlm(), - [Tool(name="search", description="Search things", inputSchema={"type": "object"})], + [Tool(name="search", description="Search things", input_schema={"type": "object"})], ) ctx = CommandContext( agent_provider=_Provider(agent), diff --git a/tests/unit/fast_agent/core/test_a2a_remote_agent.py b/tests/unit/fast_agent/core/test_a2a_remote_agent.py index b6ce826b5..4e131d29b 100644 --- a/tests/unit/fast_agent/core/test_a2a_remote_agent.py +++ b/tests/unit/fast_agent/core/test_a2a_remote_agent.py @@ -12,7 +12,7 @@ def test_a2a_use_history_falls_back_to_agent_config_when_request_defaulted() -> a2a_config=A2AAgentConfig(url="http://example.test"), ) - assert agent._resolve_turn_use_history(RequestParams(maxTokens=100)) is False + assert agent._resolve_turn_use_history(RequestParams(max_tokens=100)) is False def test_a2a_use_history_respects_explicit_request_override() -> None: diff --git a/tests/unit/fast_agent/core/test_harness.py b/tests/unit/fast_agent/core/test_harness.py index 81c547bd3..abc436fd6 100644 --- a/tests/unit/fast_agent/core/test_harness.py +++ b/tests/unit/fast_agent/core/test_harness.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent import ( AgentAuth, diff --git a/tests/unit/fast_agent/core/test_mcp_content.py b/tests/unit/fast_agent/core/test_mcp_content.py index 506f82895..a91580427 100644 --- a/tests/unit/fast_agent/core/test_mcp_content.py +++ b/tests/unit/fast_agent/core/test_mcp_content.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest -from mcp.types import EmbeddedResource, ImageContent, TextContent +from mcp_types import EmbeddedResource, ImageContent, TextContent from fast_agent.mcp.mcp_content import ( Assistant, @@ -49,7 +49,7 @@ def test_image_content(): assert message["role"] == "user" assert isinstance(message["content"], ImageContent) assert message["content"].type == "image" - assert message["content"].mimeType == "image/png" + assert message["content"].mime_type == "image/png" # Decode the base64 data decoded = base64.b64decode(message["content"].data) @@ -58,7 +58,7 @@ def test_image_content(): # Test with raw data message = MCPImage(data=b"fake image data", mime_type="image/jpeg", role="assistant") assert message["role"] == "assistant" - assert message["content"].mimeType == "image/jpeg" + assert message["content"].mime_type == "image/jpeg" decoded = base64.b64decode(message["content"].data) assert decoded == b"fake image data" @@ -92,7 +92,7 @@ def test_resource_content(): assert message["role"] == "user" assert isinstance(message["content"], EmbeddedResource) assert message["content"].type == "resource" - assert message["content"].resource.mimeType == "text/plain" + assert message["content"].resource.mime_type == "text/plain" assert message["content"].resource.text == "Hello, world!" # Test with binary file @@ -101,7 +101,7 @@ def test_resource_content(): assert message["role"] == "assistant" assert isinstance(message["content"], EmbeddedResource) assert message["content"].type == "resource" - assert message["content"].resource.mimeType == "application/pdf" + assert message["content"].resource.mime_type == "application/pdf" # Decode the base64 data decoded = base64.b64decode(message["content"].resource.blob) @@ -150,11 +150,10 @@ def test_prompt_function(): assert messages[0]["role"] == "assistant" # Test with EmbeddedResource - from mcp.types import TextResourceContents - from pydantic import AnyUrl + from mcp_types import TextResourceContents text_resource = TextResourceContents( - uri=AnyUrl("file:///test/example.txt"), text="Resource content", mimeType="text/plain" + uri="file:///test/example.txt", text="Resource content", mime_type="text/plain" ) resource = EmbeddedResource(type="resource", resource=text_resource) messages = MCPPrompt(resource) @@ -163,11 +162,10 @@ def test_prompt_function(): assert messages[0]["content"] == resource # Test with ResourceContents - from mcp.types import TextResourceContents - from pydantic import AnyUrl + from mcp_types import TextResourceContents text_resource = TextResourceContents( - uri=AnyUrl("file:///test/example.txt"), text="Sample text", mimeType="text/plain" + uri="file:///test/example.txt", text="Sample text", mime_type="text/plain" ) messages = MCPPrompt(text_resource) assert len(messages) == 1 @@ -176,7 +174,7 @@ def test_prompt_function(): assert messages[0]["content"].resource == text_resource # Test with ReadResourceResult - from mcp.types import ReadResourceResult + from mcp_types import ReadResourceResult resource_result = ReadResourceResult(contents=[text_resource, text_resource]) messages = MCPPrompt(resource_result) @@ -198,7 +196,7 @@ def test_prompt_function(): # Test with direct ImageContent image_content = ImageContent( - type="image", data="ZmFrZSBpbWFnZSBkYXRh", mimeType="image/png" + type="image", data="ZmFrZSBpbWFnZSBkYXRh", mime_type="image/png" ) messages = MCPPrompt(image_content, role="assistant") assert len(messages) == 1 diff --git a/tests/unit/fast_agent/core/test_prompt.py b/tests/unit/fast_agent/core/test_prompt.py index f88b0e394..de74701b8 100644 --- a/tests/unit/fast_agent/core/test_prompt.py +++ b/tests/unit/fast_agent/core/test_prompt.py @@ -7,7 +7,7 @@ import tempfile from pathlib import Path -from mcp.types import ( +from mcp_types import ( EmbeddedResource, ImageContent, PromptMessage, @@ -137,12 +137,11 @@ def test_with_file_paths(): assert decoded == b"fake image data" # Test with ResourceContents and EmbeddedResource - from mcp.types import ReadResourceResult, TextResourceContents - from pydantic import AnyUrl + from mcp_types import ReadResourceResult, TextResourceContents # Create a TextResourceContents text_resource = TextResourceContents( - uri=AnyUrl("file:///test/example.txt"), text="Sample text", mimeType="text/plain" + uri="file:///test/example.txt", text="Sample text", mime_type="text/plain" ) # Test with ResourceContent @@ -178,7 +177,7 @@ def test_with_file_paths(): # Test with direct ImageContent image_content = ImageContent( - type="image", data="ZmFrZSBpbWFnZSBkYXRh", mimeType="image/png" + type="image", data="ZmFrZSBpbWFnZSBkYXRh", mime_type="image/png" ) message = Prompt.assistant(image_content) assert message.role == "assistant" diff --git a/tests/unit/fast_agent/history/test_compaction.py b/tests/unit/fast_agent/history/test_compaction.py index 7514017ea..d17ef60bc 100644 --- a/tests/unit/fast_agent/history/test_compaction.py +++ b/tests/unit/fast_agent/history/test_compaction.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any import pytest -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, CallToolResult, @@ -49,7 +49,7 @@ def _user(text: str, *, template: bool = False) -> PromptMessageExtended: def _tool_result(text: str) -> PromptMessageExtended: msg = PromptMessageExtended(role="user", content=[]) msg.tool_results = { - "call_1": CallToolResult(content=[TextContent(type="text", text=text)], isError=False) + "call_1": CallToolResult(content=[TextContent(type="text", text=text)], is_error=False) } return msg @@ -355,7 +355,7 @@ def test_counts_tool_traffic(self): def test_counts_non_text_content_and_channels(self): plain = estimate_tokens([_user("hello")]) msg = _user("hello") - msg.content.append(ImageContent(type="image", data="x" * 20_000, mimeType="image/png")) + msg.content.append(ImageContent(type="image", data="x" * 20_000, mime_type="image/png")) msg.channels = {"diagnostics": [TextContent(type="text", text="y" * 20_000)]} assert estimate_tokens([msg]) > plain + 5_000 @@ -446,7 +446,7 @@ async def test_summarizer_sees_compact_region_plus_prompt(self): async def test_reduces_retained_tail_when_tail_exceeds_budget(self): huge_tail = _user("latest huge artifact") huge_tail.content.append( - ImageContent(type="image", data="x" * 300_000, mimeType="image/png") + ImageContent(type="image", data="x" * 300_000, mime_type="image/png") ) history = _turn("one", "1") + _turn("two", "2") + [huge_tail, _assistant("after huge")] agent = _FakeAgent(history, summary="summary including huge artifact") diff --git a/tests/unit/fast_agent/history/test_process_poll_folding.py b/tests/unit/fast_agent/history/test_process_poll_folding.py index de1f8f639..4ab9d77eb 100644 --- a/tests/unit/fast_agent/history/test_process_poll_folding.py +++ b/tests/unit/fast_agent/history/test_process_poll_folding.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone import pytest -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, CallToolResult, @@ -67,7 +67,7 @@ def _poll_result( ) -> PromptMessageExtended: result = CallToolResult( content=[TextContent(type="text", text=f"poll output {index}")], - isError=status == "failed", + is_error=status == "failed", ) line_count = index if output_line_count is None else output_line_count metadata: dict[str, object] = { diff --git a/tests/unit/fast_agent/history/test_tool_activities.py b/tests/unit/fast_agent/history/test_tool_activities.py index 44d452a35..a5f1a9eeb 100644 --- a/tests/unit/fast_agent/history/test_tool_activities.py +++ b/tests/unit/fast_agent/history/test_tool_activities.py @@ -1,4 +1,4 @@ -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from fast_agent.constants import ANTHROPIC_ASSISTANT_RAW_CONTENT, ANTHROPIC_SERVER_TOOLS_CHANNEL from fast_agent.history.tool_activities import ( @@ -87,7 +87,7 @@ def test_tool_activities_include_standard_tool_calls_and_results() -> None: tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="world")], - isError=False, + is_error=False, ) }, ) diff --git a/tests/unit/fast_agent/hooks/test_history_trimmer.py b/tests/unit/fast_agent/hooks/test_history_trimmer.py index e0030b9a5..e79c35910 100644 --- a/tests/unit/fast_agent/hooks/test_history_trimmer.py +++ b/tests/unit/fast_agent/hooks/test_history_trimmer.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import pytest -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.hooks.history_trimmer import ( _find_turn_start, @@ -20,7 +20,7 @@ def _make_user_msg(text: str, has_tool_results: bool = False) -> PromptMessageEx msg = PromptMessageExtended(role="user", content=[TextContent(type="text", text=text)]) if has_tool_results: # Simulate tool results by setting the attribute - msg.tool_results = {"tool1": CallToolResult(content=[], isError=False)} + msg.tool_results = {"tool1": CallToolResult(content=[], is_error=False)} return msg diff --git a/tests/unit/fast_agent/hooks/test_session_history.py b/tests/unit/fast_agent/hooks/test_session_history.py index e18d0cd34..4c0ee30c8 100644 --- a/tests/unit/fast_agent/hooks/test_session_history.py +++ b/tests/unit/fast_agent/hooks/test_session_history.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.hooks.hook_context import HookContext from fast_agent.hooks.session_history import save_session_history diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_anthropic_cache_control.py b/tests/unit/fast_agent/llm/provider/anthropic/test_anthropic_cache_control.py index ea65827c2..91765576b 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_anthropic_cache_control.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_anthropic_cache_control.py @@ -6,7 +6,7 @@ BetaTextBlockParam, BetaThinkingBlockParam, ) -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.llm.provider.anthropic.cache_planner import AnthropicCachePlanner from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_default_headers.py b/tests/unit/fast_agent/llm/provider/anthropic/test_default_headers.py index ea6d9693f..0d91587c6 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_default_headers.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_default_headers.py @@ -1,115 +1,115 @@ -"""Tests for custom default headers support in Anthropic provider.""" - -import yaml - -from fast_agent.config import AnthropicSettings, Settings -from fast_agent.context import Context -from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM - - -class TestDefaultHeaders: - """Test custom headers support for Anthropic provider.""" - - def test_default_headers_passed_to_config(self): - """Test that default_headers are properly configured.""" - custom_headers = { - "Ocp-Apim-Subscription-Key": "test-key-123", - "X-Custom-Header": "custom-value", - } - - settings = AnthropicSettings( - api_key="test-api-key", - default_headers=custom_headers, - ) - - assert settings.default_headers is not None - assert settings.default_headers == custom_headers - assert settings.default_headers["Ocp-Apim-Subscription-Key"] == "test-key-123" - assert settings.default_headers["X-Custom-Header"] == "custom-value" - - def test_default_headers_retrieved_by_llm(self): - """Test that LLM can retrieve default headers from config.""" - custom_headers = { - "Ocp-Apim-Subscription-Key": "test-key-456", - } - - settings = Settings() - settings.anthropic = AnthropicSettings( - api_key="test-api-key", - default_headers=custom_headers, - ) - - ctx = Context() - ctx.config = settings - - llm = AnthropicLLM(context=ctx) - retrieved_headers = llm._default_headers() - - assert retrieved_headers is not None - assert retrieved_headers == custom_headers - assert retrieved_headers["Ocp-Apim-Subscription-Key"] == "test-key-456" - - def test_no_default_headers_returns_none(self): - """Test that when no headers are configured, None is returned.""" - settings = Settings() - settings.anthropic = AnthropicSettings(api_key="test-api-key") - - ctx = Context() - ctx.config = settings - - llm = AnthropicLLM(context=ctx) - retrieved_headers = llm._default_headers() - - assert retrieved_headers is None - - def test_empty_default_headers(self): - """Test that empty headers dict can be configured.""" - settings = AnthropicSettings( - api_key="test-api-key", - default_headers={}, - ) - - assert settings.default_headers == {} - - def test_default_headers_from_yaml(self): - """Test that default_headers can be loaded from YAML configuration.""" - yaml_config = """ -anthropic: - api_key: "dummy" - base_url: "https://llm-api.amd.com/Anthropic" - default_headers: - "Ocp-Apim-Subscription-Key": "test-subscription-key" - "X-Custom-Header": "custom-value" -""" - config_dict = yaml.safe_load(yaml_config) - - settings = AnthropicSettings(**config_dict["anthropic"]) - - assert settings.api_key == "dummy" - assert settings.base_url == "https://llm-api.amd.com/Anthropic" - assert settings.default_headers is not None - assert settings.default_headers["Ocp-Apim-Subscription-Key"] == "test-subscription-key" - assert settings.default_headers["X-Custom-Header"] == "custom-value" - - def test_yaml_config_with_multiple_headers(self): - """Test YAML configuration with multiple custom headers.""" - yaml_config = """ -anthropic: - api_key: "test-key" - base_url: "https://custom-api.example.com" - default_headers: - "Authorization": "Bearer custom-token" - "X-API-Key": "api-key-value" - "X-Request-ID": "req-12345" - "X-Tenant-ID": "tenant-abc" -""" - config_dict = yaml.safe_load(yaml_config) - - settings = AnthropicSettings(**config_dict["anthropic"]) - - assert settings.default_headers is not None - assert len(settings.default_headers) == 4 - assert settings.default_headers["Authorization"] == "Bearer custom-token" - assert settings.default_headers["X-API-Key"] == "api-key-value" - assert settings.default_headers["X-Request-ID"] == "req-12345" - assert settings.default_headers["X-Tenant-ID"] == "tenant-abc" +"""Tests for custom default headers support in Anthropic provider.""" + +import yaml + +from fast_agent.config import AnthropicSettings, Settings +from fast_agent.context import Context +from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM + + +class TestDefaultHeaders: + """Test custom headers support for Anthropic provider.""" + + def test_default_headers_passed_to_config(self): + """Test that default_headers are properly configured.""" + custom_headers = { + "Ocp-Apim-Subscription-Key": "test-key-123", + "X-Custom-Header": "custom-value", + } + + settings = AnthropicSettings( + api_key="test-api-key", + default_headers=custom_headers, + ) + + assert settings.default_headers is not None + assert settings.default_headers == custom_headers + assert settings.default_headers["Ocp-Apim-Subscription-Key"] == "test-key-123" + assert settings.default_headers["X-Custom-Header"] == "custom-value" + + def test_default_headers_retrieved_by_llm(self): + """Test that LLM can retrieve default headers from config.""" + custom_headers = { + "Ocp-Apim-Subscription-Key": "test-key-456", + } + + settings = Settings() + settings.anthropic = AnthropicSettings( + api_key="test-api-key", + default_headers=custom_headers, + ) + + ctx = Context() + ctx.config = settings + + llm = AnthropicLLM(context=ctx) + retrieved_headers = llm._default_headers() + + assert retrieved_headers is not None + assert retrieved_headers == custom_headers + assert retrieved_headers["Ocp-Apim-Subscription-Key"] == "test-key-456" + + def test_no_default_headers_returns_none(self): + """Test that when no headers are configured, None is returned.""" + settings = Settings() + settings.anthropic = AnthropicSettings(api_key="test-api-key") + + ctx = Context() + ctx.config = settings + + llm = AnthropicLLM(context=ctx) + retrieved_headers = llm._default_headers() + + assert retrieved_headers is None + + def test_empty_default_headers(self): + """Test that empty headers dict can be configured.""" + settings = AnthropicSettings( + api_key="test-api-key", + default_headers={}, + ) + + assert settings.default_headers == {} + + def test_default_headers_from_yaml(self): + """Test that default_headers can be loaded from YAML configuration.""" + yaml_config = """ +anthropic: + api_key: "dummy" + base_url: "https://llm-api.amd.com/Anthropic" + default_headers: + "Ocp-Apim-Subscription-Key": "test-subscription-key" + "X-Custom-Header": "custom-value" +""" + config_dict = yaml.safe_load(yaml_config) + + settings = AnthropicSettings(**config_dict["anthropic"]) + + assert settings.api_key == "dummy" + assert settings.base_url == "https://llm-api.amd.com/Anthropic" + assert settings.default_headers is not None + assert settings.default_headers["Ocp-Apim-Subscription-Key"] == "test-subscription-key" + assert settings.default_headers["X-Custom-Header"] == "custom-value" + + def test_yaml_config_with_multiple_headers(self): + """Test YAML configuration with multiple custom headers.""" + yaml_config = """ +anthropic: + api_key: "test-key" + base_url: "https://custom-api.example.com" + default_headers: + "Authorization": "Bearer custom-token" + "X-API-Key": "api-key-value" + "X-Request-ID": "req-12345" + "X-Tenant-ID": "tenant-abc" +""" + config_dict = yaml.safe_load(yaml_config) + + settings = AnthropicSettings(**config_dict["anthropic"]) + + assert settings.default_headers is not None + assert len(settings.default_headers) == 4 + assert settings.default_headers["Authorization"] == "Bearer custom-token" + assert settings.default_headers["X-API-Key"] == "api-key-value" + assert settings.default_headers["X-Request-ID"] == "req-12345" + assert settings.default_headers["X-Tenant-ID"] == "tenant-abc" diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_file_uploads.py b/tests/unit/fast_agent/llm/provider/anthropic/test_file_uploads.py index ddfbac283..9609737d7 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_file_uploads.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_file_uploads.py @@ -4,8 +4,7 @@ from types import SimpleNamespace import pytest -from mcp.types import BlobResourceContents, EmbeddedResource -from pydantic import AnyUrl +from mcp_types import BlobResourceContents, EmbeddedResource from fast_agent.config import AnthropicSettings, Settings from fast_agent.context import Context @@ -55,8 +54,8 @@ async def test_prepare_anthropic_file_resources_uploads_office_documents() -> No anthropic = _FakeAnthropic() docx_bytes = b"PK\x03\x04docx" resource = BlobResourceContents( - uri=AnyUrl("file:///tmp/report.docx"), - mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri="file:///tmp/report.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", blob=base64.b64encode(docx_bytes).decode("ascii"), ) message = PromptMessageExtended( @@ -68,7 +67,7 @@ async def test_prepare_anthropic_file_resources_uploads_office_documents() -> No meta = dict(resource.meta or {}) assert meta[ANTHROPIC_FILE_ID_META_KEY] == "file_1" - assert anthropic.beta.files.calls == [("report.docx", docx_bytes, resource.mimeType)] + assert anthropic.beta.files.calls == [("report.docx", docx_bytes, resource.mime_type)] @pytest.mark.asyncio @@ -79,13 +78,13 @@ async def test_prepare_anthropic_file_resources_caches_repeated_uploads() -> Non blob = base64.b64encode(docx_bytes).decode("ascii") first = BlobResourceContents( - uri=AnyUrl("file:///tmp/report.docx"), - mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri="file:///tmp/report.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", blob=blob, ) second = BlobResourceContents( - uri=AnyUrl("file:///tmp/report.docx"), - mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri="file:///tmp/report.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", blob=blob, ) messages = [ @@ -110,7 +109,7 @@ async def test_prepare_anthropic_file_resources_infers_document_mime_from_uri() anthropic = _FakeAnthropic() docx_bytes = b"PK\x03\x04docx" resource = BlobResourceContents( - uri=AnyUrl("file:///tmp/report.docx"), + uri="file:///tmp/report.docx", blob=base64.b64encode(docx_bytes).decode("ascii"), ) message = PromptMessageExtended( diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_opentelemetry_compatibility.py b/tests/unit/fast_agent/llm/provider/anthropic/test_opentelemetry_compatibility.py index a2d093b1b..618ac5186 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_opentelemetry_compatibility.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_opentelemetry_compatibility.py @@ -87,7 +87,7 @@ async def test_stream_without_opentelemetry(self): with patch.object(llm, "_process_stream", new_callable=AsyncMock) as mock_process: mock_process.return_value = (final_message, [], []) - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.mcp.prompt_message_extended import PromptMessageExtended @@ -139,7 +139,7 @@ async def coroutine_stream_call(): # Simulate OpenTelemetry behavior: stream() returns a coroutine mock_anthropic.beta.messages.stream.return_value = coroutine_stream_call() - from mcp.types import TextContent + from mcp_types import TextContent from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py b/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py index f5b157f3e..5d53d0cb1 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py @@ -13,7 +13,7 @@ BetaUsage, ) from mcp import Tool -from mcp.types import TextContent +from mcp_types import TextContent from pydantic import BaseModel from fast_agent.config import AnthropicSettings, Settings @@ -284,7 +284,7 @@ def test_opus_47_task_budget_merges_into_output_config() -> None: args, thinking_enabled = llm._build_anthropic_base_args( model="claude-opus-4-7", messages=[], - params=RequestParams(maxTokens=1024), + params=RequestParams(max_tokens=1024), history=None, current_extended=None, request_tools=[], @@ -319,7 +319,7 @@ def test_vertex_opus_47_task_budget_merges_into_output_config() -> None: args, thinking_enabled = llm._build_anthropic_base_args( model="claude-opus-4-7", messages=[], - params=RequestParams(maxTokens=1024), + params=RequestParams(max_tokens=1024), history=None, current_extended=None, request_tools=[], @@ -476,7 +476,7 @@ def test_json_structured_output_uses_output_config_format(): args, thinking_enabled = llm._build_anthropic_base_args( model="claude-opus-4-6", messages=[], - params=RequestParams(maxTokens=1024), + params=RequestParams(max_tokens=1024), history=None, current_extended=None, request_tools=[], @@ -496,7 +496,7 @@ def test_json_structured_output_sanitizes_map_additional_properties(): args, _ = llm._build_anthropic_base_args( model="claude-opus-4-6", messages=[], - params=RequestParams(maxTokens=1024), + params=RequestParams(max_tokens=1024), history=None, current_extended=None, request_tools=[], @@ -567,7 +567,7 @@ def test_json_structured_output_merges_with_adaptive_effort(): args, thinking_enabled = llm._build_anthropic_base_args( model="claude-opus-4-6", messages=[], - params=RequestParams(maxTokens=1024), + params=RequestParams(max_tokens=1024), history=None, current_extended=None, request_tools=[], @@ -770,7 +770,7 @@ def test_json_structured_output_uses_raw_schema_when_supplied() -> None: args, _ = llm._build_anthropic_base_args( model="claude-opus-4-6", messages=[], - params=RequestParams(maxTokens=1024, structured_schema=schema), + params=RequestParams(max_tokens=1024, structured_schema=schema), history=None, current_extended=None, request_tools=[], @@ -798,7 +798,7 @@ def test_json_structured_output_transforms_raw_schema_with_anthropic_sdk() -> No args, _ = llm._build_anthropic_base_args( model="claude-opus-4-6", messages=[], - params=RequestParams(maxTokens=1024, structured_schema=schema), + params=RequestParams(max_tokens=1024, structured_schema=schema), history=None, current_extended=None, request_tools=[], @@ -839,7 +839,7 @@ async def test_json_structured_output_preserves_regular_tools() -> None: tool = Tool( name="lookup_probe_payload", description="Return the probe payload for validation.", - inputSchema={ + input_schema={ "type": "object", "properties": {}, "additionalProperties": False, @@ -870,7 +870,7 @@ def test_structured_schema_with_tools_is_deferred_until_tool_result() -> None: tool = Tool( name="lookup_probe_payload", description="Return the probe payload for validation.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) params = RequestParams(structured_schema=schema, structured_tool_policy="defer") @@ -894,7 +894,7 @@ def test_structured_schema_with_no_tools_policy_preserves_schema_for_tool_suppre tool = Tool( name="lookup_probe_payload", description="Return the probe payload for validation.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) params = RequestParams(structured_schema=schema, structured_tool_policy="no_tools") diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_tool_id_sanitization.py b/tests/unit/fast_agent/llm/provider/anthropic/test_tool_id_sanitization.py index a43e7104f..3d4ad2d97 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_tool_id_sanitization.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_tool_id_sanitization.py @@ -1,6 +1,6 @@ from typing import cast -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from fast_agent.llm.provider.anthropic.multipart_converter_anthropic import AnthropicConverter from fast_agent.types import PromptMessageExtended @@ -27,7 +27,7 @@ def test_sanitizes_tool_use_ids_for_assistant_calls(): def test_sanitizes_tool_use_ids_for_tool_results(): dirty_id = "functions.fetch_magic_string:0" expected = "functions_fetch_magic_string_0" - result = CallToolResult(content=[TextContent(type="text", text="done")], isError=False) + result = CallToolResult(content=[TextContent(type="text", text="done")], is_error=False) msg = PromptMessageExtended(role="user", content=[], tool_results={dirty_id: result}) diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_web_tools.py b/tests/unit/fast_agent/llm/provider/anthropic/test_web_tools.py index 37d54834b..6331c6864 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_web_tools.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_web_tools.py @@ -20,7 +20,7 @@ BetaToolUseBlock, BetaUsage, ) -from mcp.types import TextContent +from mcp_types import TextContent from pydantic import ValidationError from fast_agent.config import ( diff --git a/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py b/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py index 385f85dc5..b2af9b377 100644 --- a/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py +++ b/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py @@ -2,7 +2,7 @@ import pytest from mcp import Tool -from mcp.types import CallToolRequest, CallToolRequestParams, ListToolsResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, ListToolsResult, TextContent from pydantic import BaseModel from fast_agent.config import BedrockSettings @@ -58,7 +58,7 @@ def test_bedrock_convert_messages_to_bedrock_includes_tool_use_block(): def test_resolve_tool_use_name_uses_mapped_name(): tool_list = ListToolsResult( - tools=[Tool(name="my-tool", description="demo", inputSchema={"type": "object"})] + tools=[Tool(name="my-tool", description="demo", input_schema={"type": "object"})] ) tool_name_mapping = {"my_tool": "my-tool"} @@ -203,7 +203,7 @@ async def test_bedrock_structured_schema_path_preserves_tools(monkeypatch): tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) captured_tools = None @@ -248,7 +248,7 @@ async def test_bedrock_structured_schema_prompt_preserves_history_and_tool_conte tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) tool_call = CallToolRequest( method="tools/call", diff --git a/tests/unit/fast_agent/llm/providers/test_google_converter.py b/tests/unit/fast_agent/llm/providers/test_google_converter.py index e0e7b8fc5..0e365c707 100644 --- a/tests/unit/fast_agent/llm/providers/test_google_converter.py +++ b/tests/unit/fast_agent/llm/providers/test_google_converter.py @@ -1,14 +1,13 @@ import base64 from google.genai import types -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolResult, EmbeddedResource, TextContent, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.llm.provider.google.google_converter import GoogleConverter from fast_agent.types import ( @@ -25,7 +24,7 @@ def test_convert_function_results_to_google_text_only(): # Create a simple text-only tool result result = CallToolResult( - content=[TextContent(type="text", text="Weather is sunny")], isError=False + content=[TextContent(type="text", text="Weather is sunny")], is_error=False ) contents = converter.convert_function_results_to_google([("weather", "call_123", result)]) @@ -117,7 +116,7 @@ def test_convert_video_resource(): resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///path/to/video.mp4"), mimeType="video/mp4", blob=encoded_video + uri="file:///path/to/video.mp4", mime_type="video/mp4", blob=encoded_video ), ) @@ -153,7 +152,7 @@ def test_convert_mixed_content_video_text(): video_resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///video.mp4"), mimeType="video/mp4", blob=encoded_video + uri="file:///video.mp4", mime_type="video/mp4", blob=encoded_video ), ) @@ -189,8 +188,8 @@ def test_convert_audio_blob_resource(): audio_resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///audio.mp3"), - mimeType="audio/mpeg", + uri="file:///audio.mp3", + mime_type="audio/mpeg", blob=encoded_audio, ), ) @@ -215,8 +214,8 @@ def test_convert_youtube_url_video(): youtube_resource = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), - mimeType="video/mp4", + uri="https://www.youtube.com/watch?v=dQw4w9WgXcQ", + mime_type="video/mp4", text="YouTube video", ), ) @@ -344,7 +343,7 @@ def test_convert_resource_link_in_tool_result(): # Create a tool result with a video ResourceLink link = video_link("https://storage.example.com/output.mp4", name="generated_video") - result = CallToolResult(content=[link], isError=False) + result = CallToolResult(content=[link], is_error=False) contents = converter.convert_function_results_to_google([("video_generator", None, result)]) @@ -376,12 +375,12 @@ def test_convert_video_blob_in_tool_result(): resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///video.mp4"), - mimeType="video/mp4", + uri="file:///video.mp4", + mime_type="video/mp4", blob=encoded_video, ), ) - result = CallToolResult(content=[resource], isError=False) + result = CallToolResult(content=[resource], is_error=False) contents = converter.convert_function_results_to_google([("attach_media", None, result)]) @@ -407,12 +406,12 @@ def test_convert_audio_blob_in_tool_result(): resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///audio.mp3"), - mimeType="audio/mpeg", + uri="file:///audio.mp3", + mime_type="audio/mpeg", blob=encoded_audio, ), ) - result = CallToolResult(content=[resource], isError=False) + result = CallToolResult(content=[resource], is_error=False) contents = converter.convert_function_results_to_google([("attach_media", None, result)]) @@ -440,7 +439,7 @@ def test_convert_resource_link_text_in_tool_result(): mime_type="application/yaml", ) - result = CallToolResult(content=[link], isError=False) + result = CallToolResult(content=[link], is_error=False) contents = converter.convert_function_results_to_google([("config_reader", None, result)]) @@ -491,8 +490,8 @@ def test_convert_multiple_function_results_into_single_content(): """Test that multiple tool results are combined into a single Content object.""" converter = GoogleConverter() - result1 = CallToolResult(content=[TextContent(type="text", text="Output 1")], isError=False) - result2 = CallToolResult(content=[TextContent(type="text", text="Output 2")], isError=False) + result1 = CallToolResult(content=[TextContent(type="text", text="Output 1")], is_error=False) + result2 = CallToolResult(content=[TextContent(type="text", text="Output 2")], is_error=False) contents = converter.convert_function_results_to_google( [ diff --git a/tests/unit/fast_agent/llm/providers/test_google_search.py b/tests/unit/fast_agent/llm/providers/test_google_search.py index 2fb4a1fed..94d3ff92a 100644 --- a/tests/unit/fast_agent/llm/providers/test_google_search.py +++ b/tests/unit/fast_agent/llm/providers/test_google_search.py @@ -141,7 +141,7 @@ async def test_google_completion_injects_search_tool_with_custom_tools() -> None custom_tool = MagicMock() custom_tool.name = "my_tool" custom_tool.description = "custom tool" - custom_tool.inputSchema = {} + custom_tool.input_schema = {} with ( patch.object(llm, "_initialize_google_client", return_value=mock_client), diff --git a/tests/unit/fast_agent/llm/providers/test_google_thinking.py b/tests/unit/fast_agent/llm/providers/test_google_thinking.py index de02bad61..b1d3d9532 100644 --- a/tests/unit/fast_agent/llm/providers/test_google_thinking.py +++ b/tests/unit/fast_agent/llm/providers/test_google_thinking.py @@ -4,7 +4,7 @@ import pytest from google.genai import types as google_types -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.config import Settings from fast_agent.context import Context diff --git a/tests/unit/fast_agent/llm/providers/test_groq_reasoning.py b/tests/unit/fast_agent/llm/providers/test_groq_reasoning.py index bc5239cd5..d5486a55b 100644 --- a/tests/unit/fast_agent/llm/providers/test_groq_reasoning.py +++ b/tests/unit/fast_agent/llm/providers/test_groq_reasoning.py @@ -59,7 +59,7 @@ def test_resolve_reasoning_effort_maps_to_groq_wire_values() -> None: def test_prepare_api_request_thinking_on_sends_parsed_format() -> None: llm = _groq_llm("qwen/qwen3.6-27b") - args = llm._prepare_api_request([], None, RequestParams(maxTokens=512)) + args = llm._prepare_api_request([], None, RequestParams(max_tokens=512)) assert args["reasoning_effort"] == "default" assert args["extra_body"]["reasoning_format"] == "parsed" @@ -67,7 +67,7 @@ def test_prepare_api_request_thinking_on_sends_parsed_format() -> None: def test_prepare_api_request_thinking_off_omits_format() -> None: llm = _groq_llm("qwen/qwen3.6-27b") llm.set_reasoning_effort(ReasoningEffortSetting(kind="toggle", value=False)) - args = llm._prepare_api_request([], None, RequestParams(maxTokens=512)) + args = llm._prepare_api_request([], None, RequestParams(max_tokens=512)) assert args["reasoning_effort"] == "none" assert "reasoning_format" not in (args.get("extra_body") or {}) @@ -77,6 +77,6 @@ def test_tag_mode_groq_reasoner_is_unshaped() -> None: # shaping (no reasoning_effort / reasoning_format injection). llm = _groq_llm("qwen/qwen3-32b") assert llm._reasoning_mode == "tags" - args = llm._prepare_api_request([], None, RequestParams(maxTokens=512)) + args = llm._prepare_api_request([], None, RequestParams(max_tokens=512)) assert "reasoning_effort" not in args assert "reasoning_format" not in (args.get("extra_body") or {}) diff --git a/tests/unit/fast_agent/llm/providers/test_llm_anthropic_caching.py b/tests/unit/fast_agent/llm/providers/test_llm_anthropic_caching.py index d4bb1c69f..bb352781a 100644 --- a/tests/unit/fast_agent/llm/providers/test_llm_anthropic_caching.py +++ b/tests/unit/fast_agent/llm/providers/test_llm_anthropic_caching.py @@ -19,7 +19,7 @@ BetaTextBlockParam, BetaUsage, ) -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, CallToolResult, @@ -299,7 +299,7 @@ def test_build_request_messages_avoids_duplicate_tool_results(self): llm = self._create_llm() tool_id = "toolu_test" tool_result = CallToolResult( - content=[TextContent(type="text", text="result payload")], isError=False + content=[TextContent(type="text", text="result payload")], is_error=False ) user_msg = PromptMessageExtended( role="user", content=[], tool_results={tool_id: tool_result} @@ -555,8 +555,8 @@ def test_enabled_cache_diagnostics_records_pending_null_response(self): async def test_anthropic_tool_payload_preserves_order_across_requests(self): llm = self._create_llm(cache_mode="auto") tools = [ - Tool(name="zeta", description="last alphabetically", inputSchema={"type": "object"}), - Tool(name="alpha", description="first alphabetically", inputSchema={"type": "object"}), + Tool(name="zeta", description="last alphabetically", input_schema={"type": "object"}), + Tool(name="alpha", description="first alphabetically", input_schema={"type": "object"}), ] first = await llm._prepare_tools("claude-sonnet-5", tools=tools) diff --git a/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py b/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py index bcdb3dc60..3715eebfd 100644 --- a/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py +++ b/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py @@ -4,7 +4,7 @@ import pytest from google.genai import types as google_types from mcp import Tool -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from pydantic import BaseModel from fast_agent.config import GoogleSettings, Settings @@ -168,7 +168,7 @@ def test_structured_schema_with_tools_is_deferred_until_tool_result() -> None: tool = Tool( name="lookup_probe_payload", description="Return the probe payload for validation.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) params = RequestParams(structured_schema=schema, structured_tool_policy="defer") @@ -249,7 +249,7 @@ def _initialize_google_client(self): Tool( name="lookup_probe_payload", description="Return the probe payload for validation.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) ], ) @@ -384,7 +384,7 @@ def _initialize_google_client(self): Tool( name="lookup_probe_payload", description="Return the probe payload for validation.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) ], ) @@ -483,7 +483,7 @@ def _initialize_google_client(self): tool_results={ "call_weather": CallToolResult( content=[TextContent(type="text", text="Sunny")], - isError=False, + is_error=False, ) }, ), @@ -493,7 +493,7 @@ def _initialize_google_client(self): Tool( name="weather", description="Check weather", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) ], ) diff --git a/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py b/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py index 20f59bd97..36757abd4 100644 --- a/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py +++ b/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py @@ -1,5 +1,5 @@ import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from openai.types.chat import ChatCompletionMessageParam from fast_agent.constants import REASONING diff --git a/tests/unit/fast_agent/llm/providers/test_llm_tensorzero_unit.py b/tests/unit/fast_agent/llm/providers/test_llm_tensorzero_unit.py index 2d6bc943f..e025c0ee3 100644 --- a/tests/unit/fast_agent/llm/providers/test_llm_tensorzero_unit.py +++ b/tests/unit/fast_agent/llm/providers/test_llm_tensorzero_unit.py @@ -54,7 +54,7 @@ def test_initialize_default_params_sets_defaults(t0_llm): t0_llm.instruction = "Test System Prompt" params = t0_llm._initialize_default_params({"model": "test_chat"}) assert params.model == "tensorzero::function_name::test_chat" - assert params.systemPrompt == "Test System Prompt" + assert params.system_prompt == "Test System Prompt" assert params.parallel_tool_calls is True assert params.max_iterations == DEFAULT_MAX_ITERATIONS assert params.use_history is True diff --git a/tests/unit/fast_agent/llm/providers/test_metaai_responses.py b/tests/unit/fast_agent/llm/providers/test_metaai_responses.py index a3759d2b0..c54a852eb 100644 --- a/tests/unit/fast_agent/llm/providers/test_metaai_responses.py +++ b/tests/unit/fast_agent/llm/providers/test_metaai_responses.py @@ -1,7 +1,7 @@ import json from types import SimpleNamespace -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.config import ( MetaAISettings, diff --git a/tests/unit/fast_agent/llm/providers/test_multipart_converter_anthropic.py b/tests/unit/fast_agent/llm/providers/test_multipart_converter_anthropic.py index 0fe3c5607..4e6c4d8be 100644 --- a/tests/unit/fast_agent/llm/providers/test_multipart_converter_anthropic.py +++ b/tests/unit/fast_agent/llm/providers/test_multipart_converter_anthropic.py @@ -4,7 +4,7 @@ from collections.abc import Iterable, Mapping from typing import cast -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolRequest, CallToolRequestParams, @@ -15,7 +15,6 @@ TextContent, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.constants import ( ANTHROPIC_ASSISTANT_RAW_CONTENT, @@ -59,8 +58,8 @@ def block_content(block: dict[str, object]) -> list[dict[str, object]]: def create_pdf_resource(pdf_base64) -> EmbeddedResource: pdf_resource: BlobResourceContents = BlobResourceContents( - uri=AnyUrl("test://example.com/document.pdf"), - mimeType="application/pdf", + uri="test://example.com/document.pdf", + mime_type="application/pdf", blob=pdf_base64, ) return EmbeddedResource(type="resource", resource=pdf_resource) @@ -93,7 +92,7 @@ def test_image_content_conversion(self): """Test conversion of ImageContent to Anthropic image block.""" # Create an image content message image_content = ImageContent( - type="image", data=self.sample_image_base64, mimeType="image/jpeg" + type="image", data=self.sample_image_base64, mime_type="image/jpeg" ) multipart = PromptMessageExtended(role="user", content=[image_content]) @@ -114,8 +113,8 @@ def test_embedded_resource_text_conversion(self): """Test conversion of text-based EmbeddedResource to Anthropic document block.""" # Create a text resource text_resource = TextResourceContents( - uri=AnyUrl("test://example.com/document.txt"), - mimeType="text/plain", + uri="test://example.com/document.txt", + mime_type="text/plain", text=self.sample_text, ) embedded_resource = EmbeddedResource(type="resource", resource=text_resource) @@ -156,8 +155,8 @@ def test_embedded_resource_image_url_conversion(self): """Test conversion of image URL in EmbeddedResource to Anthropic image block.""" # Create an image resource with URL image_resource = BlobResourceContents( - uri=AnyUrl("https://example.com/image.jpg"), - mimeType="image/jpeg", + uri="https://example.com/image.jpg", + mime_type="image/jpeg", blob=self.sample_image_base64, # This should be ignored for URL ) embedded_resource = EmbeddedResource(type="resource", resource=image_resource) @@ -180,8 +179,8 @@ def test_resource_link_image_url_conversion(self): """Test conversion of image ResourceLink to Anthropic image block.""" resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/image.jpg"), - mimeType="image/jpeg", + uri="https://example.com/image.jpg", + mime_type="image/jpeg", name="image.jpg", ) multipart = PromptMessageExtended(role="user", content=[resource]) @@ -200,8 +199,8 @@ def test_resource_link_image_url_conversion(self): def test_embedded_resource_office_document_uses_uploaded_file_source(self): """Test office documents use Anthropic file document source when pre-uploaded.""" resource = BlobResourceContents( - uri=AnyUrl("file:///tmp/report.docx"), - mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri="file:///tmp/report.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", blob=PDF_BASE64, ) resource.meta = {ANTHROPIC_FILE_ID_META_KEY: "file_abc123"} @@ -221,7 +220,7 @@ def test_assistant_role_restrictions(self): # Create mixed content for assistant text_content = TextContent(type="text", text=self.sample_text) image_content = ImageContent( - type="image", data=self.sample_image_base64, mimeType="image/jpeg" + type="image", data=self.sample_image_base64, mime_type="image/jpeg" ) multipart = PromptMessageExtended(role="assistant", content=[text_content, image_content]) @@ -239,7 +238,7 @@ def test_multiple_content_blocks(self): # Create multiple content blocks text_content1 = TextContent(type="text", text="First text") image_content = ImageContent( - type="image", data=self.sample_image_base64, mimeType="image/jpeg" + type="image", data=self.sample_image_base64, mime_type="image/jpeg" ) text_content2 = TextContent(type="text", text="Second text") @@ -265,7 +264,7 @@ def test_unsupported_mime_type_handling(self): image_content = ImageContent( type="image", data=self.sample_image_base64, - mimeType="image/bmp", # Unsupported in Anthropic API + mime_type="image/bmp", # Unsupported in Anthropic API ) text_content = TextContent(type="text", text="This is some text") multipart = PromptMessageExtended(role="user", content=[text_content, image_content]) @@ -288,8 +287,8 @@ def test_svg_resource_conversion(self): # Create an embedded SVG resource svg_content = '' svg_resource = TextResourceContents( - uri=AnyUrl("test://example.com/image.svg"), - mimeType="image/svg+xml", + uri="test://example.com/image.svg", + mime_type="image/svg+xml", text=svg_content, ) embedded_resource = EmbeddedResource(type="resource", resource=svg_resource) @@ -322,8 +321,8 @@ def test_embedded_resource_pdf_url_conversion(self): """Test conversion of PDF URL in EmbeddedResource to Anthropic document block.""" # Create a PDF resource with URL pdf_resource = BlobResourceContents( - uri=AnyUrl("https://example.com/document.pdf"), - mimeType="application/pdf", + uri="https://example.com/document.pdf", + mime_type="application/pdf", blob=base64.b64encode(b"fake_pdf_data").decode("utf-8"), ) embedded_resource = EmbeddedResource(type="resource", resource=pdf_resource) @@ -347,12 +346,12 @@ def test_mixed_content_with_unsupported_formats(self): unsupported_image = ImageContent( type="image", data=self.sample_image_base64, - mimeType="image/bmp", # Unsupported + mime_type="image/bmp", # Unsupported ) supported_image = ImageContent( type="image", data=self.sample_image_base64, - mimeType="image/jpeg", # Supported + mime_type="image/jpeg", # Supported ) multipart = PromptMessageExtended( @@ -376,7 +375,7 @@ def test_multipart_with_tool_results_and_content(self): """Test conversion of PromptMessageExtended with both tool_results and content.""" # Create tool results tool_result = CallToolResult( - content=[TextContent(type="text", text="Tool execution result")], isError=False + content=[TextContent(type="text", text="Tool execution result")], is_error=False ) # Create additional content @@ -454,8 +453,8 @@ def test_unsupported_binary_resource_conversion(self): # Create an embedded resource with binary data binary_data = base64.b64encode(b"This is binary data").decode("utf-8") # 20 bytes of data binary_resource = BlobResourceContents( - uri=AnyUrl("test://example.com/data.bin"), - mimeType="application/octet-stream", + uri="test://example.com/data.bin", + mime_type="application/octet-stream", blob=binary_data, ) embedded_resource = EmbeddedResource(type="resource", resource=binary_resource) @@ -492,7 +491,7 @@ def test_pdf_result_conversion(self): # Create a tool result with text and PDF content text_content = TextContent(type="text", text=self.sample_text) pdf_content = create_pdf_resource(PDF_BASE64) - tool_result = CallToolResult(content=[text_content, pdf_content], isError=False) + tool_result = CallToolResult(content=[text_content, pdf_content], is_error=False) # Convert to Anthropic format anthropic_msg = AnthropicConverter.create_tool_results_message( @@ -530,7 +529,7 @@ def test_binary_only_tool_result_conversion(self): """Binary-only tool result should be a single tool_result with a document inside.""" # Create a PDF embedded resource with no text content pdf_content = create_pdf_resource(PDF_BASE64) - tool_result = CallToolResult(content=[pdf_content], isError=False) + tool_result = CallToolResult(content=[pdf_content], is_error=False) # Test the message creation with this result anthropic_msg = AnthropicConverter.create_tool_results_message( @@ -548,11 +547,11 @@ def test_tool_result_image_resource_link_conversion(self): """Image ResourceLinks in tool results become Anthropic URL image blocks.""" resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/image.jpg"), - mimeType="image/jpeg", + uri="https://example.com/image.jpg", + mime_type="image/jpeg", name="image.jpg", ) - tool_result = CallToolResult(content=[resource], isError=False) + tool_result = CallToolResult(content=[resource], is_error=False) anthropic_msg = AnthropicConverter.create_tool_results_message( [(self.tool_use_id, tool_result)] @@ -567,11 +566,11 @@ def test_tool_result_pdf_resource_link_conversion(self): """PDF ResourceLinks in tool results become Anthropic URL document blocks.""" resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/document.pdf"), - mimeType="application/pdf", + uri="https://example.com/document.pdf", + mime_type="application/pdf", name="document.pdf", ) - tool_result = CallToolResult(content=[resource], isError=False) + tool_result = CallToolResult(content=[resource], is_error=False) anthropic_msg = AnthropicConverter.create_tool_results_message( [(self.tool_use_id, tool_result)] @@ -586,11 +585,11 @@ def test_tool_result_unsupported_resource_link_falls_back_to_text(self): """Unsupported ResourceLinks in tool results remain visible as text.""" resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/archive.zip"), - mimeType="application/zip", + uri="https://example.com/archive.zip", + mime_type="application/zip", name="archive.zip", ) - tool_result = CallToolResult(content=[resource], isError=False) + tool_result = CallToolResult(content=[resource], is_error=False) anthropic_msg = AnthropicConverter.create_tool_results_message( [(self.tool_use_id, tool_result)] @@ -605,12 +604,12 @@ def test_create_tool_results_message(self): # Create two tool results text_content = TextContent(type="text", text=self.sample_text) image_content = ImageContent( - type="image", data=self.sample_image_base64, mimeType="image/jpeg" + type="image", data=self.sample_image_base64, mime_type="image/jpeg" ) - tool_result1 = CallToolResult(content=[text_content], isError=False) + tool_result1 = CallToolResult(content=[text_content], is_error=False) - tool_result2 = CallToolResult(content=[image_content], isError=False) + tool_result2 = CallToolResult(content=[image_content], is_error=False) tool_use_id1 = "tool_id_1" tool_use_id2 = "tool_id_2" @@ -642,7 +641,7 @@ def test_create_tool_results_message_with_error(self): """Test creation of tool results message with error flag.""" # Create a tool result with error flag set error_content = TextContent(type="text", text="Error: Something went wrong") - tool_result = CallToolResult(content=[error_content], isError=True) + tool_result = CallToolResult(content=[error_content], is_error=True) tool_use_id = "tool_error_id" # Convert to Anthropic message @@ -663,7 +662,7 @@ def test_create_tool_results_message_with_error(self): def test_create_tool_results_message_with_empty_content(self): """Test creation of tool results message with empty content.""" # Create a tool result with no content - tool_result = CallToolResult(content=[], isError=False) + tool_result = CallToolResult(content=[], is_error=False) tool_use_id = "tool_empty_id" # Convert to Anthropic message @@ -687,9 +686,9 @@ def test_create_tool_results_message_with_unsupported_image(self): unsupported_image = ImageContent( type="image", data=self.sample_image_base64, - mimeType="image/bmp", # Unsupported + mime_type="image/bmp", # Unsupported ) - tool_result = CallToolResult(content=[unsupported_image], isError=False) + tool_result = CallToolResult(content=[unsupported_image], is_error=False) tool_use_id = "tool_unsupported_id" # Convert to Anthropic message @@ -711,9 +710,9 @@ def test_create_tool_results_message_with_mixed_content(self): # Create a tool result with text and image content text_content = TextContent(type="text", text=self.sample_text) image_content = ImageContent( - type="image", data=self.sample_image_base64, mimeType="image/jpeg" + type="image", data=self.sample_image_base64, mime_type="image/jpeg" ) - tool_result = CallToolResult(content=[text_content, image_content], isError=False) + tool_result = CallToolResult(content=[text_content, image_content], is_error=False) tool_use_id = "tool_mixed_id" # Convert to Anthropic message @@ -736,12 +735,12 @@ def test_create_tool_results_message_with_text_resource(self): markdown_content = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://test/content"), - mimeType="text/markdown", + uri="resource://test/content", + mime_type="text/markdown", text="markdown text", ), ) - tool_result = CallToolResult(content=[markdown_content], isError=False) + tool_result = CallToolResult(content=[markdown_content], is_error=False) tool_use_id = "tool_markdown_id" # Convert to Anthropic message @@ -778,7 +777,7 @@ def create_text_resource( normalized_path = filename_or_uri.replace("\\", "/") uri = f"file://{normalized_path}" if normalized_path.startswith("/") else f"file:///{normalized_path}" - return TextResourceContents(uri=AnyUrl(uri), mimeType=mime_type, text=text) + return TextResourceContents(uri=uri, mime_type=mime_type, text=text) class TestAnthropicAssistantConverter(unittest.TestCase): @@ -819,7 +818,7 @@ def test_single_text_content_conversion(self): def test_single_image_content_conversion(self): """Test conversion of a single ImageContent item to Anthropic format.""" image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image_content = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") multipart = PromptMessageExtended(role="user", content=[image_content]) anthropic_msg = AnthropicConverter.convert_to_anthropic(multipart) @@ -835,8 +834,8 @@ def test_single_image_content_conversion(self): def test_single_embedded_resource_conversion(self): """Test conversion of a single embedded resource item to Anthropic format.""" text_resource = TextResourceContents( - uri=AnyUrl("test://example.com/document.txt"), - mimeType="text/plain", + uri="test://example.com/document.txt", + mime_type="text/plain", text="This is a text resource", ) embedded_resource = EmbeddedResource(type="resource", resource=text_resource) @@ -1290,7 +1289,7 @@ def test_assistant_non_text_content_stripped(self): image_content = ImageContent( type="image", data=base64.b64encode(b"fake_image_data").decode("utf-8"), - mimeType="image/jpeg", + mime_type="image/jpeg", ) multipart = PromptMessageExtended(role="assistant", content=[text_content, image_content]) @@ -1310,8 +1309,8 @@ def test_assistant_embedded_resource_stripped(self): text_content = TextContent(type="text", text=self.sample_text) resource_content = TextResourceContents( - uri=AnyUrl("test://example.com/document.txt"), - mimeType="text/plain", + uri="test://example.com/document.txt", + mime_type="text/plain", text="Some document content", ) embedded_resource = EmbeddedResource(type="resource", resource=resource_content) diff --git a/tests/unit/fast_agent/llm/providers/test_multipart_converter_google.py b/tests/unit/fast_agent/llm/providers/test_multipart_converter_google.py index bd74f90ab..12bfc9145 100644 --- a/tests/unit/fast_agent/llm/providers/test_multipart_converter_google.py +++ b/tests/unit/fast_agent/llm/providers/test_multipart_converter_google.py @@ -2,7 +2,7 @@ import unittest from typing import TYPE_CHECKING -from mcp.types import ( +from mcp_types import ( CallToolResult, ImageContent, TextContent, @@ -26,7 +26,7 @@ def test_tool_result_conversion(self): """Test conversion of CallToolResult to OpenAI tool message.""" # Create a tool result with text content text_content = TextContent(type="text", text=self.sample_text) - tool_result = CallToolResult(content=[text_content], isError=False) + tool_result = CallToolResult(content=[text_content], is_error=False) # Create a tool call ID # tool_call_id = "call_abc123" @@ -49,15 +49,15 @@ def test_multiple_tool_results_with_mixed_content(self): """Test conversion of multiple tool results with different content types.""" # Create first tool result with text only text_result = CallToolResult( - content=[TextContent(type="text", text="Text-only result")], isError=False + content=[TextContent(type="text", text="Text-only result")], is_error=False ) # Create second tool result with image image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image_content = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") image_result = CallToolResult( content=[TextContent(type="text", text="Here's the image:"), image_content], - isError=False, + is_error=False, ) # Create tool names/ids diff --git a/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py b/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py index 4b784cb13..302b0b72d 100644 --- a/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py +++ b/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, cast import pytest -from mcp.types import ( +from mcp_types import ( Annotations, AudioContent, BlobResourceContents, @@ -18,7 +18,6 @@ TextResourceContents, ) from openai import AsyncOpenAI -from pydantic import AnyUrl from fast_agent.llm.provider.openai import llm_openai from fast_agent.llm.provider.openai.multipart_converter_openai import ( @@ -138,7 +137,7 @@ def test_image_content_conversion(self): image_content = ImageContent( type="image", data=self.sample_image_base64, - mimeType="image/jpeg", + mime_type="image/jpeg", annotations=Annotations(audience=["user"], priority=0.5), ) multipart = PromptMessageExtended(role="user", content=[image_content]) @@ -162,8 +161,8 @@ def test_embedded_resource_text_conversion(self): """Test conversion of text-based EmbeddedResource to OpenAI text content with fastagent:file tags.""" # Create a text resource text_resource = TextResourceContents( - uri=AnyUrl("test://example.com/document.txt"), - mimeType="text/plain", + uri="test://example.com/document.txt", + mime_type="text/plain", text=self.sample_text, ) embedded_resource = EmbeddedResource(type="resource", resource=text_resource) @@ -189,8 +188,8 @@ def test_embedded_resource_pdf_conversion(self): # Create a PDF resource pdf_base64 = base64.b64encode(b"fake_pdf_data").decode("utf-8") pdf_resource = BlobResourceContents( - uri=AnyUrl("test://example.com/document.pdf"), - mimeType="application/pdf", + uri="test://example.com/document.pdf", + mime_type="application/pdf", blob=pdf_base64, ) embedded_resource = EmbeddedResource(type="resource", resource=pdf_resource) @@ -215,8 +214,8 @@ def test_embedded_resource_image_url_conversion(self): """Test conversion of image URL in EmbeddedResource to OpenAI image block.""" # Create an image resource with URL image_resource = BlobResourceContents( - uri=AnyUrl("https://example.com/image.jpg"), - mimeType="image/jpeg", + uri="https://example.com/image.jpg", + mime_type="image/jpeg", blob=self.sample_image_base64, # This would be ignored for URL in OpenAI ) embedded_resource = EmbeddedResource(type="resource", resource=image_resource) @@ -240,9 +239,9 @@ def test_linked_resource_conversion(self): """Test conversion of text-based EmbeddedResource to OpenAI text content with fastagent:file tags.""" # Create a text resource resource_link = ResourceLink( - uri=AnyUrl("test://example.com/document.txt"), + uri="test://example.com/document.txt", type="resource_link", - mimeType="text/plain", + mime_type="text/plain", description="Some description", name="some name", ) @@ -265,9 +264,9 @@ def test_linked_resource_conversion(self): def test_image_resource_link_conversion(self): """Test conversion of image ResourceLink to OpenAI image_url content.""" resource_link = ResourceLink( - uri=AnyUrl("https://example.com/image.jpg"), + uri="https://example.com/image.jpg", type="resource_link", - mimeType="image/jpeg", + mime_type="image/jpeg", name="image.jpg", ) multipart = PromptMessageExtended(role="user", content=[resource_link]) @@ -284,9 +283,9 @@ def test_image_resource_link_conversion(self): def test_document_resource_link_conversion(self): """Test conversion of document ResourceLink to OpenAI file content.""" resource_link = ResourceLink( - uri=AnyUrl("https://example.com/report.pdf"), + uri="https://example.com/report.pdf", type="resource_link", - mimeType="application/pdf", + mime_type="application/pdf", name="report.pdf", ) multipart = PromptMessageExtended(role="user", content=[resource_link]) @@ -306,7 +305,7 @@ def test_multiple_content_blocks(self): # Create multiple content blocks text_content1 = TextContent(type="text", text="First text") image_content = ImageContent( - type="image", data=self.sample_image_base64, mimeType="image/jpeg" + type="image", data=self.sample_image_base64, mime_type="image/jpeg" ) text_content2 = TextContent(type="text", text="Second text") @@ -329,7 +328,7 @@ def test_multiple_content_blocks(self): self.assertEqual(text_part(openai_msg, 2), "Second text") def test_audio_content_conversion_gets_explicit_text_fallback(self): - audio_content = AudioContent(type="audio", data="ZmFrZV9hdWRpbw==", mimeType="audio/wav") + audio_content = AudioContent(type="audio", data="ZmFrZV9hdWRpbw==", mime_type="audio/wav") multipart = PromptMessageExtended(role="user", content=[audio_content]) openai_msgs = OpenAIConverter.convert_to_openai(multipart) @@ -346,8 +345,8 @@ def test_svg_resource_conversion(self): # Create an embedded SVG resource svg_content = '' svg_resource = TextResourceContents( - uri=AnyUrl("test://example.com/image.svg"), - mimeType="image/svg+xml", + uri="test://example.com/image.svg", + mime_type="image/svg+xml", text=svg_content, ) embedded_resource = EmbeddedResource(type="resource", resource=svg_resource) @@ -386,8 +385,8 @@ def test_code_file_conversion(self): # Create a code resource code_resource = TextResourceContents( - uri=AnyUrl("test://example.com/example.py"), - mimeType="text/x-python", + uri="test://example.com/example.py", + mime_type="text/x-python", text=code_text, ) embedded_resource = EmbeddedResource(type="resource", resource=code_resource) @@ -461,7 +460,7 @@ def test_convert_prompt_message_to_openai_user_image(self): """Test conversion of a PromptMessage with image content to OpenAI format.""" # Create a PromptMessage with ImageContent image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image_content = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") prompt_message = PromptMessageExtended(role="user", content=[image_content]) # Convert to OpenAI format @@ -481,8 +480,8 @@ def test_convert_prompt_message_embedded_resource_to_openai(self): """Test conversion of a PromptMessage with embedded resource to OpenAI format.""" # Create a PromptMessage with embedded text resource text_resource = TextResourceContents( - uri=AnyUrl("test://example.com/document.txt"), - mimeType="text/plain", + uri="test://example.com/document.txt", + mime_type="text/plain", text="This is a text resource", ) embedded_resource = EmbeddedResource(type="resource", resource=text_resource) @@ -520,7 +519,7 @@ def test_assistant_multimodal_content_is_converted_to_text(self): role="assistant", content=[ TextContent(type="text", text="Assistant summary"), - ImageContent(type="image", data=image_base64, mimeType="image/jpeg"), + ImageContent(type="image", data=image_base64, mime_type="image/jpeg"), ], ) @@ -536,7 +535,7 @@ def test_assistant_image_only_content_gets_text_fallback(self): image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") multipart = PromptMessageExtended( role="assistant", - content=[ImageContent(type="image", data=image_base64, mimeType="image/jpeg")], + content=[ImageContent(type="image", data=image_base64, mime_type="image/jpeg")], ) openai_msgs = OpenAIConverter.convert_to_openai(multipart) @@ -550,7 +549,7 @@ def test_assistant_image_only_content_gets_text_fallback(self): def test_assistant_audio_content_gets_text_fallback(self): multipart = PromptMessageExtended( role="assistant", - content=[AudioContent(type="audio", data="ZmFrZV9hdWRpbw==", mimeType="audio/wav")], + content=[AudioContent(type="audio", data="ZmFrZV9hdWRpbw==", mime_type="audio/wav")], ) openai_msgs = OpenAIConverter.convert_to_openai(multipart) @@ -604,7 +603,7 @@ def test_tool_result_conversion(self): """Test conversion of CallToolResult to OpenAI tool message.""" # Create a tool result with text content text_content = TextContent(type="text", text=self.sample_text) - tool_result = CallToolResult(content=[text_content], isError=False) + tool_result = CallToolResult(content=[text_content], is_error=False) # Create a tool call ID tool_call_id = "call_abc123" @@ -625,15 +624,15 @@ def test_multiple_tool_results_with_mixed_content(self): """Test conversion of multiple tool results with different content types.""" # Create first tool result with text only text_result = CallToolResult( - content=[TextContent(type="text", text="Text-only result")], isError=False + content=[TextContent(type="text", text="Text-only result")], is_error=False ) # Create second tool result with image image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image_content = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") image_result = CallToolResult( content=[TextContent(type="text", text="Here's the image:"), image_content], - isError=False, + is_error=False, ) # Create tool call IDs @@ -671,20 +670,20 @@ def test_tool_result_with_mixed_content(self): # Add an image image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image_content = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") # Add a PDF file pdf_base64 = base64.b64encode(b"fake_pdf_data").decode("utf-8") pdf_resource = BlobResourceContents( - uri=AnyUrl("test://example.com/document.pdf"), - mimeType="application/pdf", + uri="test://example.com/document.pdf", + mime_type="application/pdf", blob=pdf_base64, ) pdf_embedded = EmbeddedResource(type="resource", resource=pdf_resource) # Create the tool result with all content types tool_result = CallToolResult( - content=[text_content, image_content, pdf_embedded], isError=False + content=[text_content, image_content, pdf_embedded], is_error=False ) # Create a tool call ID @@ -716,16 +715,16 @@ def test_tool_result_with_mixed_content(self): def test_tool_result_prefers_structured_content_for_tool_text(self): """Test that structuredContent becomes the canonical tool text payload.""" image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image_content = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") tool_result = CallToolResult( content=[ TextContent(type="text", text="stale summary"), - TextContent(type="text", text="ignored detail"), - image_content, - ], - isError=False, - ) - setattr(tool_result, "structuredContent", {"status": "fresh", "value": 3}) + TextContent(type="text", text="ignored detail"), + image_content, + ], + structured_content={"status": "fresh", "value": 3}, + is_error=False, + ) converted = OpenAIConverter.convert_tool_result_to_openai( tool_result=tool_result, @@ -744,14 +743,14 @@ def test_tool_result_prefers_structured_content_for_tool_text(self): def test_tool_result_resource_link_pdf_becomes_user_file_message(self): """PDF ResourceLinks in tool results use OpenAI's internal file_url shape.""" resource_link = ResourceLink( - uri=AnyUrl("https://example.com/report.pdf"), + uri="https://example.com/report.pdf", type="resource_link", - mimeType="application/pdf", + mime_type="application/pdf", name="report.pdf", ) tool_result = CallToolResult( content=[TextContent(type="text", text="attached"), resource_link], - isError=False, + is_error=False, ) converted = OpenAIConverter.convert_tool_result_to_openai( @@ -771,11 +770,11 @@ def test_tool_result_resource_link_pdf_becomes_user_file_message(self): def test_empty_schema_behavior(self): """Test adjustment of parameters for empty schema.""" - inputSchema = { + input_schema = { "type": "object", } an_llm = llm_openai.OpenAILLM(Provider.OPENAI) - adjusted = an_llm.adjust_schema(inputSchema) + adjusted = an_llm.adjust_schema(input_schema) assert adjusted["properties"] == {} @@ -811,7 +810,7 @@ def test_mixed_content_with_concatenation(self): text1 = TextContent(type="text", text="Text before image.") image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") - image = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + image = ImageContent(type="image", data=image_base64, mime_type="image/jpeg") text2 = TextContent(type="text", text="Text after image.") text3 = TextContent(type="text", text="More text after image.") @@ -844,7 +843,7 @@ def test_tool_result_with_concatenation(self): text1 = TextContent(type="text", text="First part of result.") text2 = TextContent(type="text", text="Second part of result.") - tool_result = CallToolResult(content=[text1, text2], isError=False) + tool_result = CallToolResult(content=[text1, text2], is_error=False) # Convert with concatenation enabled tool_message = OpenAIConverter.convert_tool_result_to_openai( @@ -866,8 +865,8 @@ def test_convert_unsupported_binary_format(self): # Create a binary resource with an unsupported format binary_base64 = base64.b64encode(b"fake_binary_data").decode("utf-8") binary_resource = BlobResourceContents( - uri=AnyUrl("test://example.com/data.bin"), - mimeType="application/octet-stream", + uri="test://example.com/data.bin", + mime_type="application/octet-stream", blob=binary_base64, ) embedded_resource = EmbeddedResource(type="resource", resource=binary_resource) diff --git a/tests/unit/fast_agent/llm/providers/test_responses_helpers.py b/tests/unit/fast_agent/llm/providers/test_responses_helpers.py index e198cbe50..826640028 100644 --- a/tests/unit/fast_agent/llm/providers/test_responses_helpers.py +++ b/tests/unit/fast_agent/llm/providers/test_responses_helpers.py @@ -6,7 +6,7 @@ from typing import Any, Literal import pytest -from mcp.types import ( +from mcp_types import ( BlobResourceContents, CallToolRequest, CallToolRequestParams, @@ -19,7 +19,7 @@ ) from openai import AsyncOpenAI from openai.types.responses import Response, ResponseFunctionToolCall, ResponseUsage -from pydantic import AnyUrl, ValidationError +from pydantic import ValidationError from fast_agent.config import ( CodexResponsesSettings, @@ -445,7 +445,7 @@ def test_convert_tool_results_serializes_apply_patch_as_custom_tool_call_output( harness = _ContentHarness() harness._tool_kind_map["call_patch"] = "custom" - result = SimpleNamespace(content=[TextContent(type="text", text="Success")], isError=False) + result = SimpleNamespace(content=[TextContent(type="text", text="Success")], is_error=False) items = harness._convert_tool_results({"call_patch": result}) assert items == [ @@ -463,11 +463,11 @@ def test_convert_tool_results_prefers_structured_content_and_keeps_attachments() result = SimpleNamespace( content=[ TextContent(type="text", text="stale summary"), - ImageContent(type="image", data=image_data, mimeType="image/jpeg"), + ImageContent(type="image", data=image_data, mime_type="image/jpeg"), ], - isError=False, + is_error=False, ) - setattr(result, "structuredContent", {"fresh": True}) + setattr(result, "structured_content", {"fresh": True}) items = harness._convert_tool_results({"call_1": result}) @@ -487,13 +487,13 @@ def test_convert_tool_results_resource_link_pdf_becomes_user_input_file() -> Non harness = _ContentHarness() resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/report.pdf"), - mimeType="application/pdf", + uri="https://example.com/report.pdf", + mime_type="application/pdf", name="report.pdf", ) result = SimpleNamespace( content=[TextContent(type="text", text="attached"), resource], - isError=False, + is_error=False, ) items = harness._convert_tool_results({"call_1": result}) @@ -584,7 +584,7 @@ def test_build_response_args_marks_function_tools_non_strict() -> None: tool = Tool( name="search", description="Search the connected MCP server.", - inputSchema={ + input_schema={ "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], @@ -704,7 +704,7 @@ def test_build_response_args_ignores_non_wire_custom_tool_meta_extra() -> None: "name": "edit_script", "description": "Edit a script with a freeform patch.", "inputSchema": {"type": "object"}, - "meta": { + "customMeta": { OPENAI_RESPONSES_CUSTOM_TOOL_META_KEY: { "type": "custom", "format": { @@ -884,11 +884,11 @@ def test_codexresponses_lite_uses_internal_request_contract() -> None: "content": [{"type": "input_text", "text": "hello"}], } ] - tool = Tool(name="lookup", description="Lookup", inputSchema={"type": "object"}) + tool = Tool(name="lookup", description="Lookup", input_schema={"type": "object"}) args = llm._build_response_args( input_items, - RequestParams(model="gpt-5.6-luna", systemPrompt="instructions"), + RequestParams(model="gpt-5.6-luna", system_prompt="instructions"), [tool], ) @@ -944,7 +944,7 @@ def test_codexresponses_standard_model_omits_lite_contract(model_name: str) -> N llm = _build_responses_family_llm(Provider.CODEX_RESPONSES, model_name=model_name) args = llm._build_response_args( [{"type": "message", "role": "user", "content": []}], - RequestParams(model=model_name, systemPrompt="instructions"), + RequestParams(model=model_name, system_prompt="instructions"), None, ) @@ -1082,7 +1082,7 @@ def test_convert_content_parts_text_and_image(): parts = harness._convert_content_parts( [ TextContent(type="text", text="Hello"), - ImageContent(type="image", data=image_data, mimeType="image/png"), + ImageContent(type="image", data=image_data, mime_type="image/png"), ], role="user", ) @@ -1097,8 +1097,8 @@ def test_convert_content_parts_embedded_text_resource_inlines_as_input_text(): resource = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file:///tmp/example.py"), - mimeType="text/x-python", + uri="file:///tmp/example.py", + mime_type="text/x-python", text="print('hello')", ), ) @@ -1123,8 +1123,8 @@ def test_convert_content_parts_office_resource_stays_as_input_file(): resource = EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///tmp/example.docx"), - mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + uri="file:///tmp/example.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", blob=docx_data, ), ) @@ -1144,8 +1144,8 @@ def test_convert_content_parts_image_resource_link_uses_remote_input_image(): harness = _ContentHarness() resource = ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/image.png"), - mimeType="image/png", + uri="https://example.com/image.png", + mime_type="image/png", name="image.png", ) diff --git a/tests/unit/fast_agent/llm/providers/test_responses_websocket.py b/tests/unit/fast_agent/llm/providers/test_responses_websocket.py index a025fbe7b..57f990f0e 100644 --- a/tests/unit/fast_agent/llm/providers/test_responses_websocket.py +++ b/tests/unit/fast_agent/llm/providers/test_responses_websocket.py @@ -8,7 +8,7 @@ import pytest from aiohttp import WSMsgType -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL, FAST_AGENT_RETRY from fast_agent.llm.provider.openai.codex_responses import CodexResponsesLLM diff --git a/tests/unit/fast_agent/llm/providers/test_xai_responses.py b/tests/unit/fast_agent/llm/providers/test_xai_responses.py index 7e7db2354..144a9948d 100644 --- a/tests/unit/fast_agent/llm/providers/test_xai_responses.py +++ b/tests/unit/fast_agent/llm/providers/test_xai_responses.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import pytest -from mcp.types import TextContent +from mcp_types import TextContent from openai.types.responses import ResponseUsage from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails diff --git a/tests/unit/fast_agent/llm/test_cache_control_application.py b/tests/unit/fast_agent/llm/test_cache_control_application.py index 8bf93e892..8e1a14034 100644 --- a/tests/unit/fast_agent/llm/test_cache_control_application.py +++ b/tests/unit/fast_agent/llm/test_cache_control_application.py @@ -1,7 +1,7 @@ import unittest from typing import Any, cast -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.llm.provider.anthropic.multipart_converter_anthropic import AnthropicConverter from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/unit/fast_agent/llm/test_clear_behavior.py b/tests/unit/fast_agent/llm/test_clear_behavior.py index 2f1dcb987..b647babc6 100644 --- a/tests/unit/fast_agent/llm/test_clear_behavior.py +++ b/tests/unit/fast_agent/llm/test_clear_behavior.py @@ -1,5 +1,5 @@ import pytest -from mcp.types import GetPromptResult, PromptMessage, TextContent +from mcp_types import GetPromptResult, PromptMessage, TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent diff --git a/tests/unit/fast_agent/llm/test_max_tokens_acp_regression.py b/tests/unit/fast_agent/llm/test_max_tokens_acp_regression.py index 319cf0133..42f80de85 100644 --- a/tests/unit/fast_agent/llm/test_max_tokens_acp_regression.py +++ b/tests/unit/fast_agent/llm/test_max_tokens_acp_regression.py @@ -42,10 +42,10 @@ def _make_hf_llm(model: str) -> HuggingFaceLLM: @pytest.mark.parametrize( ("params", "expected_dump"), [ - (RequestParams(systemPrompt="test prompt"), {"systemPrompt": "test prompt"}), + (RequestParams(system_prompt="test prompt"), {"system_prompt": "test prompt"}), ( - RequestParams(systemPrompt="test", maxTokens=8192), - {"systemPrompt": "test", "maxTokens": 8192}, + RequestParams(system_prompt="test", max_tokens=8192), + {"system_prompt": "test", "max_tokens": 8192}, ), ], ) @@ -63,11 +63,11 @@ def test_request_params_dump_recreate_preserves_unset_max_tokens_as_none() -> No maxTokens fallback through dump/recreate flows. """ - original = RequestParams(systemPrompt="test") + original = RequestParams(system_prompt="test") recreated = RequestParams(**original.model_dump(exclude={"model"})) - assert "maxTokens" in recreated.model_dump(exclude_unset=True) - assert recreated.maxTokens is None + assert "max_tokens" in recreated.model_dump(exclude_unset=True) + assert recreated.max_tokens is None @pytest.mark.parametrize( @@ -80,26 +80,26 @@ def test_request_params_dump_recreate_preserves_unset_max_tokens_as_none() -> No def test_huggingface_llm_initialization_uses_model_aware_max_tokens(model: str) -> None: llm = _make_hf_llm(model) - assert llm.default_request_params.maxTokens == EXPECTED_KIMI_MAX_TOKENS + assert llm.default_request_params.max_tokens == EXPECTED_KIMI_MAX_TOKENS def test_acp_request_param_merge_preserves_model_aware_max_tokens() -> None: llm = _make_hf_llm("moonshotai/kimi-k2-instruct-0905") merged = llm.get_request_params( - RequestParams(systemPrompt="Updated system prompt for ACP session") + RequestParams(system_prompt="Updated system prompt for ACP session") ) - assert merged.systemPrompt == "Updated system prompt for ACP session" - assert merged.maxTokens == EXPECTED_KIMI_MAX_TOKENS + assert merged.system_prompt == "Updated system prompt for ACP session" + assert merged.max_tokens == EXPECTED_KIMI_MAX_TOKENS def test_acp_request_param_merge_allows_explicit_max_tokens_override() -> None: llm = _make_hf_llm("moonshotai/kimi-k2-instruct-0905") - merged = llm.get_request_params(RequestParams(systemPrompt="test", maxTokens=4096)) + merged = llm.get_request_params(RequestParams(system_prompt="test", max_tokens=4096)) - assert merged.maxTokens == 4096 + assert merged.max_tokens == 4096 @pytest.mark.parametrize( @@ -117,7 +117,7 @@ async def test_attach_llm_paths_preserve_model_aware_max_tokens(model_spec: str) llm = await agent.attach_llm(ModelFactory.create_factory(model_spec)) assert _is_fastagent_llm(llm) - assert llm.default_request_params.maxTokens == EXPECTED_KIMI_MAX_TOKENS + assert llm.default_request_params.max_tokens == EXPECTED_KIMI_MAX_TOKENS @pytest.mark.asyncio @@ -127,7 +127,7 @@ async def test_apply_model_flow_recomputes_model_aware_max_tokens_after_dump_rec model-specific fields so the newly selected model can restore its own limits. """ - original_params = RequestParams(systemPrompt="original prompt") + original_params = RequestParams(system_prompt="original prompt") recreated_params = RequestParams(**original_params.model_dump(exclude={"model", "maxTokens"})) agent = LlmAgent(AgentConfig(name="Test Agent")) @@ -136,8 +136,8 @@ async def test_apply_model_flow_recomputes_model_aware_max_tokens_after_dump_rec ) assert _is_fastagent_llm(llm) - assert llm.default_request_params.systemPrompt == "original prompt" - assert llm.default_request_params.maxTokens == EXPECTED_KIMI_MAX_TOKENS + assert llm.default_request_params.system_prompt == "original prompt" + assert llm.default_request_params.max_tokens == EXPECTED_KIMI_MAX_TOKENS @pytest.mark.asyncio @@ -148,7 +148,7 @@ async def test_attach_llm_then_acp_merge_preserves_model_aware_max_tokens() -> N llm = await agent.attach_llm(ModelFactory.create_factory("hf.moonshotai/kimi-k2-instruct-0905")) assert _is_fastagent_llm(llm) - merged = llm.get_request_params(RequestParams(systemPrompt="Updated for ACP session")) + merged = llm.get_request_params(RequestParams(system_prompt="Updated for ACP session")) - assert merged.systemPrompt == "Updated for ACP session" - assert merged.maxTokens == EXPECTED_KIMI_MAX_TOKENS + assert merged.system_prompt == "Updated for ACP session" + assert merged.max_tokens == EXPECTED_KIMI_MAX_TOKENS diff --git a/tests/unit/fast_agent/llm/test_model_database.py b/tests/unit/fast_agent/llm/test_model_database.py index 7315d1468..ae57f66ad 100644 --- a/tests/unit/fast_agent/llm/test_model_database.py +++ b/tests/unit/fast_agent/llm/test_model_database.py @@ -246,7 +246,7 @@ def test_huggingface_qwen35_structured_output_uses_prompted_json_object_mode() - tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = _make_hf_llm("Qwen/Qwen3.5-397B-A17B") @@ -535,20 +535,20 @@ def test_llm_uses_model_database_for_max_tokens(): factory = ModelFactory.create_factory("claude-sonnet-4-0") llm = factory(agent=agent) assert isinstance(llm, FastAgentLLM) - assert llm.default_request_params.maxTokens == 64000 + assert llm.default_request_params.max_tokens == 64000 # Test with a model that has high max_output_tokens (should get full amount) factory2 = ModelFactory.create_factory("o1") llm2 = factory2(agent=agent) assert isinstance(llm2, FastAgentLLM) - assert llm2.default_request_params.maxTokens == 100000 + assert llm2.default_request_params.max_tokens == 100000 # Test with passthrough model (should get its configured max tokens) factory3 = ModelFactory.create_factory("passthrough") llm3 = factory3(agent=agent) assert isinstance(llm3, FastAgentLLM) expected_max_tokens = ModelDatabase.get_default_max_tokens("passthrough") - assert llm3.default_request_params.maxTokens == expected_max_tokens + assert llm3.default_request_params.max_tokens == expected_max_tokens assert llm3.default_request_params.temperature is None @@ -565,7 +565,7 @@ def test_llm_usage_tracking_uses_model_database(): assert usage_accumulator is not None usage_accumulator.model = "claude-sonnet-4-0" assert usage_accumulator.context_window_size == 200000 - assert llm.default_request_params.maxTokens == 64000 # Should match ModelDatabase default + assert llm.default_request_params.max_tokens == 64000 # Should match ModelDatabase default # Test with unknown model usage_accumulator.model = "unknown-model" @@ -588,9 +588,9 @@ def test_openai_provider_preserves_all_settings(): params.max_iterations == DEFAULT_MAX_ITERATIONS ) # Should come from default setting assert params.use_history # Should come from base assert ( - params.systemPrompt == "You are a helpful assistant" + params.system_prompt == "You are a helpful assistant" ) # Should come from base (self.instruction) - assert params.maxTokens == 16384 # Model-aware from ModelDatabase (gpt-4o) + assert params.max_tokens == 16384 # Model-aware from ModelDatabase (gpt-4o) def test_model_database_stream_modes(): @@ -1019,7 +1019,7 @@ def test_huggingface_deepseek_v4_pro_uses_reasoning_content_streaming_metadata() llm = _make_hf_llm("deepseek-ai/DeepSeek-V4-Pro:fireworks-ai") assert llm.default_request_params.model == "deepseek-ai/DeepSeek-V4-Pro" - assert llm.default_request_params.maxTokens == 393_216 + assert llm.default_request_params.max_tokens == 393_216 assert getattr(llm, "_reasoning_mode", None) == "reasoning_content" args = _hf_request_args(llm) diff --git a/tests/unit/fast_agent/llm/test_model_overlays.py b/tests/unit/fast_agent/llm/test_model_overlays.py index 879ef22f7..2ed1e23b4 100644 --- a/tests/unit/fast_agent/llm/test_model_overlays.py +++ b/tests/unit/fast_agent/llm/test_model_overlays.py @@ -222,7 +222,7 @@ def test_same_provider_overlays_create_distinct_openresponses_clients(tmp_path: assert remote_llm._base_url() == "https://remote.example/v1" assert local_llm._api_key() == "" assert remote_llm._api_key() == "remote-key" - assert local_llm.default_request_params.maxTokens == 4096 + assert local_llm.default_request_params.max_tokens == 4096 finally: if previous_remote_key is None: os.environ.pop("REMOTE_QWEN_KEY", None) @@ -671,12 +671,12 @@ async def test_overlay_model_switch_reapplies_overlay_max_tokens_defaults(tmp_pa await agent.attach_llm(ModelFactory.create_factory("big-local")) assert agent.llm is not None - assert agent.llm.default_request_params.maxTokens == 8192 + assert agent.llm.default_request_params.max_tokens == 8192 await agent.set_model("tiny-local") assert agent.llm is not None - assert agent.llm.default_request_params.maxTokens == 1024 + assert agent.llm.default_request_params.max_tokens == 1024 assert agent.llm.resolved_model is not None assert agent.llm.resolved_model.overlay_name == "tiny-local" diff --git a/tests/unit/fast_agent/llm/test_prepare_arguments.py b/tests/unit/fast_agent/llm/test_prepare_arguments.py index 6cc083d78..05c7aeb56 100644 --- a/tests/unit/fast_agent/llm/test_prepare_arguments.py +++ b/tests/unit/fast_agent/llm/test_prepare_arguments.py @@ -1,7 +1,7 @@ from typing import Any, cast import pytest -from mcp.types import GetPromptResult, PromptMessage, TextContent +from mcp_types import GetPromptResult, PromptMessage, TextContent from pydantic import BaseModel from fast_agent.llm.fastagent_llm import FastAgentLLM, _mcp_metadata_var @@ -156,7 +156,7 @@ def test_prepare_arguments_with_exclusions(self): # Test with exclusions base_args = {"model": "test-model"} - params = RequestParams(model="different-model", temperature=0.7, maxTokens=1000) + params = RequestParams(model="different-model", temperature=0.7, max_tokens=1000) # Exclude model and maxTokens fields exclude_fields = {FastAgentLLM.PARAM_MODEL, FastAgentLLM.PARAM_MAX_TOKENS} @@ -254,8 +254,8 @@ def test_openai_provider_arguments(self): params = RequestParams( model="gpt-4.1", temperature=0.7, - maxTokens=2000, # This should be excluded and not conflict with max_tokens - systemPrompt="You are a helpful assistant", # This should be excluded + max_tokens=2000, # This should be excluded and not conflict with max_tokens + system_prompt="You are a helpful assistant", # This should be excluded response_format={"type": "json_object"}, use_history=True, # This should be excluded max_iterations=5, # This should be excluded @@ -318,8 +318,8 @@ def test_anthropic_provider_arguments(self): params = RequestParams( model="claude-3-7-sonnet", temperature=0.7, - maxTokens=2000, # This should be excluded - systemPrompt="You are a helpful assistant", # This should be excluded + max_tokens=2000, # This should be excluded + system_prompt="You are a helpful assistant", # This should be excluded use_history=True, # This should be excluded max_iterations=5, # This should be excluded parallel_tool_calls=True, # This should be excluded diff --git a/tests/unit/fast_agent/llm/test_provider_key_manager_hf.py b/tests/unit/fast_agent/llm/test_provider_key_manager_hf.py index d658969e0..45eccffb0 100644 --- a/tests/unit/fast_agent/llm/test_provider_key_manager_hf.py +++ b/tests/unit/fast_agent/llm/test_provider_key_manager_hf.py @@ -8,7 +8,7 @@ from types import SimpleNamespace import pytest -from mcp.types import Tool +from mcp_types import Tool from fast_agent.config import HuggingFaceSettings, Settings from fast_agent.core.exceptions import ProviderKeyError diff --git a/tests/unit/fast_agent/llm/test_request_param_resolution.py b/tests/unit/fast_agent/llm/test_request_param_resolution.py index f0d46d4f6..4f35845c5 100644 --- a/tests/unit/fast_agent/llm/test_request_param_resolution.py +++ b/tests/unit/fast_agent/llm/test_request_param_resolution.py @@ -84,4 +84,4 @@ def test_initialize_base_default_params_treats_blank_model_as_missing() -> None: ) assert params.model is None - assert params.maxTokens is None + assert params.max_tokens is None diff --git a/tests/unit/fast_agent/llm/test_request_params.py b/tests/unit/fast_agent/llm/test_request_params.py index 9d528030c..a3f73070a 100644 --- a/tests/unit/fast_agent/llm/test_request_params.py +++ b/tests/unit/fast_agent/llm/test_request_params.py @@ -69,7 +69,7 @@ def test_request_params_rejects_bool_numeric_values() -> None: def test_request_params_accepts_numeric_values() -> None: params = RequestParams( - maxTokens=10, + max_tokens=10, max_iterations=2, streaming_timeout=3.5, temperature=0.7, @@ -81,7 +81,7 @@ def test_request_params_accepts_numeric_values() -> None: repetition_penalty=1.1, ) - assert params.maxTokens == 10 + assert params.max_tokens == 10 assert params.max_iterations == 2 assert params.streaming_timeout == 3.5 assert params.temperature == 0.7 diff --git a/tests/unit/fast_agent/llm/test_sampling_converter.py b/tests/unit/fast_agent/llm/test_sampling_converter.py index c2639a48a..d6cd8c0f9 100644 --- a/tests/unit/fast_agent/llm/test_sampling_converter.py +++ b/tests/unit/fast_agent/llm/test_sampling_converter.py @@ -1,4 +1,4 @@ -from mcp.types import ( +from mcp_types import ( AudioContent, CallToolRequest, CallToolRequestParams, @@ -56,7 +56,7 @@ def test_sampling_message_to_prompt_message_image(self): """Test converting an image SamplingMessage to PromptMessageExtended""" # Create a SamplingMessage with image content image_content = ImageContent( - type="image", data="base64_encoded_image_data", mimeType="image/png" + type="image", data="base64_encoded_image_data", mime_type="image/png" ) sampling_message = SamplingMessage(role="user", content=image_content) @@ -69,7 +69,7 @@ def test_sampling_message_to_prompt_message_image(self): image_block = _image(prompt_message.content[0]) assert image_block.type == "image" assert image_block.data == "base64_encoded_image_data" - assert image_block.mimeType == "image/png" + assert image_block.mime_type == "image/png" def test_convert_messages(self): """Test converting multiple SamplingMessages to PromptMessageExtended objects""" @@ -107,7 +107,7 @@ def test_convert_messages_with_mixed_content_types(self): SamplingMessage( role="user", content=ImageContent( - type="image", data="base64_encoded_image_data", mimeType="image/png" + type="image", data="base64_encoded_image_data", mime_type="image/png" ), ), ] @@ -128,47 +128,47 @@ def test_convert_messages_with_mixed_content_types(self): image_block = _image(prompt_messages[1].content[0]) assert image_block.type == "image" assert image_block.data == "base64_encoded_image_data" - assert image_block.mimeType == "image/png" + assert image_block.mime_type == "image/png" def test_extract_request_params_full(self): """Test extracting RequestParams from CreateMessageRequestParams with all fields""" # Create a CreateMessageRequestParams with all fields request_params = CreateMessageRequestParams( messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], - maxTokens=1000, - systemPrompt="You are a helpful assistant", + max_tokens=1000, + system_prompt="You are a helpful assistant", temperature=0.7, - stopSequences=["STOP", "\n\n"], - includeContext="none", + stop_sequences=["STOP", "\n\n"], + include_context="none", ) # Extract parameters using our converter llm_params = SamplingConverter.extract_request_params(request_params) # Verify parameters - assert llm_params.maxTokens == 1000 - assert llm_params.systemPrompt == "You are a helpful assistant" + assert llm_params.max_tokens == 1000 + assert llm_params.system_prompt == "You are a helpful assistant" assert llm_params.temperature == 0.7 - assert llm_params.stopSequences == ["STOP", "\n\n"] - assert llm_params.modelPreferences == request_params.modelPreferences + assert llm_params.stop_sequences == ["STOP", "\n\n"] + assert llm_params.model_preferences == request_params.model_preferences def test_extract_request_params_minimal(self): """Test extracting RequestParams from CreateMessageRequestParams with minimal fields""" # Create a CreateMessageRequestParams with minimal fields request_params = CreateMessageRequestParams( messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], - maxTokens=100, # Only required field besides messages + max_tokens=100, # Only required field besides messages ) # Extract parameters using our converter llm_params = SamplingConverter.extract_request_params(request_params) # Verify parameters - assert llm_params.maxTokens == 100 - assert llm_params.systemPrompt is None + assert llm_params.max_tokens == 100 + assert llm_params.system_prompt is None assert llm_params.temperature is None - assert llm_params.stopSequences is None - assert llm_params.modelPreferences is None + assert llm_params.stop_sequences is None + assert llm_params.model_preferences is None def test_error_result(self): """Test creating an error result""" @@ -185,7 +185,7 @@ def test_error_result(self): assert _text(result.content).type == "text" assert _text(result.content).text == "Error in sampling: Test error" assert result.model == model - assert result.stopReason == "error" + assert result.stop_reason == "error" def test_error_result_no_model(self): """Test creating an error result without a model""" @@ -194,14 +194,14 @@ def test_error_result_no_model(self): # Verify the default model value is used assert result.model == "unknown" - assert result.stopReason == "error" + assert result.stop_reason == "error" def test_sampling_message_with_tool_result(self): """Test converting a SamplingMessage with ToolResultContent""" # Create a SamplingMessage with tool result content tool_result = ToolResultContent( type="tool_result", - toolUseId="call_123", + tool_use_id="call_123", content=[TextContent(type="text", text="Tool result: 42")], ) sampling_message = SamplingMessage(role="user", content=tool_result) @@ -244,12 +244,12 @@ def test_sampling_message_with_multiple_tool_results(self): tool_results = _sampling_content( ToolResultContent( type="tool_result", - toolUseId="call_1", + tool_use_id="call_1", content=[TextContent(type="text", text="Result 1")], ), ToolResultContent( type="tool_result", - toolUseId="call_2", + tool_use_id="call_2", content=[TextContent(type="text", text="Result 2")], ), ) diff --git a/tests/unit/fast_agent/llm/test_structured.py b/tests/unit/fast_agent/llm/test_structured.py index d43222690..d8a726826 100644 --- a/tests/unit/fast_agent/llm/test_structured.py +++ b/tests/unit/fast_agent/llm/test_structured.py @@ -2,7 +2,7 @@ import pytest from mcp import Tool -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from pydantic import BaseModel from fast_agent.llm.internal.passthrough import PassthroughLLM @@ -253,7 +253,7 @@ async def test_generate_strips_tools_for_deferred_structured_final_turn(): tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = _GeneratePrepareHarness() messages = [ @@ -290,7 +290,7 @@ async def test_generate_keeps_tools_for_deferred_first_turn(): tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = _GeneratePrepareHarness() @@ -313,7 +313,7 @@ async def test_generate_strips_tools_for_no_tools_structured_policy(): tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = _GeneratePrepareHarness() @@ -352,7 +352,7 @@ def test_openai_prepare_structured_request_defers_with_tools_until_tool_result() tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = OpenAILLM(model="gpt-4.1") messages = [Prompt.user("call the tool")] @@ -379,7 +379,7 @@ def test_structured_tool_policy_auto_uses_provider_default_and_allows_override() tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = _DefaultDeferOpenAIHarness(model="gpt-4.1") messages = [Prompt.user("call the tool")] @@ -514,7 +514,7 @@ def test_openai_compatible_prepare_structured_request_defers_until_tool_result() tool = Tool( name="lookup", description="Lookup data.", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) llm = _CompatibleStructuredHarness() original = Prompt.user("call the tool") diff --git a/tests/unit/fast_agent/mcp/prompts/test_prompt_helpers.py b/tests/unit/fast_agent/mcp/prompts/test_prompt_helpers.py index 41af2e8fc..d0dd879d7 100644 --- a/tests/unit/fast_agent/mcp/prompts/test_prompt_helpers.py +++ b/tests/unit/fast_agent/mcp/prompts/test_prompt_helpers.py @@ -3,7 +3,7 @@ """ import pytest -from mcp.types import ( +from mcp_types import ( BlobResourceContents, EmbeddedResource, ImageContent, @@ -11,7 +11,6 @@ TextContent, TextResourceContents, ) -from pydantic.networks import AnyUrl from fast_agent.mcp.helpers.content_helpers import ( get_image_data, @@ -34,7 +33,7 @@ def text_content(): @pytest.fixture def image_content(): - return ImageContent(type="image", data="base64data", mimeType="image/png") + return ImageContent(type="image", data="base64data", mime_type="image/png") @pytest.fixture @@ -42,7 +41,7 @@ def text_embedded_resource(): return EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file:///example.txt"), text="Resource content", mimeType="text/plain" + uri="file:///example.txt", text="Resource content", mime_type="text/plain" ), ) @@ -52,14 +51,14 @@ def blob_resource(): return EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///example.png"), blob="base64blobdata", mimeType="image/png" + uri="file:///example.png", blob="base64blobdata", mime_type="image/png" ), ) @pytest.fixture def text_resource(): - return TextResourceContents(text="text_resource", uri=AnyUrl("file://example.txt")) + return TextResourceContents(text="text_resource", uri="file://example.txt") # Test content type extraction diff --git a/tests/unit/fast_agent/mcp/prompts/test_prompt_server.py b/tests/unit/fast_agent/mcp/prompts/test_prompt_server.py index 5e0be8099..f05fb21ae 100644 --- a/tests/unit/fast_agent/mcp/prompts/test_prompt_server.py +++ b/tests/unit/fast_agent/mcp/prompts/test_prompt_server.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.mcp.prompts import prompt_server diff --git a/tests/unit/fast_agent/mcp/prompts/test_prompt_template.py b/tests/unit/fast_agent/mcp/prompts/test_prompt_template.py index 26bf4894f..bbc9d0300 100644 --- a/tests/unit/fast_agent/mcp/prompts/test_prompt_template.py +++ b/tests/unit/fast_agent/mcp/prompts/test_prompt_template.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from mcp.types import ImageContent, TextContent +from mcp_types import ImageContent, TextContent from fast_agent.mcp import mime_utils, resource_utils from fast_agent.mcp.prompts.prompt_load import create_messages_with_resources @@ -432,7 +432,7 @@ def test_create_image_content(self): assert isinstance(image_content, ImageContent) assert image_content.type == "image" assert image_content.data == TINY_IMAGE_PNG - assert image_content.mimeType == "image/png" + assert image_content.mime_type == "image/png" def test_binary_resource_handling(self, temp_image_file): """Test binary resource handling with images""" @@ -497,7 +497,7 @@ def test_create_messages_with_image(self, temp_image_prompt_file, temp_image_fil assert messages[1].role == "user" assert isinstance(messages[1].content, ImageContent) assert messages[1].content.type == "image" - assert messages[1].content.mimeType == "image/png" + assert messages[1].content.mime_type == "image/png" # The data should be our base64 PNG (or equivalent) assert isinstance(messages[1].content.data, str) assert len(messages[1].content.data) > 0 diff --git a/tests/unit/fast_agent/mcp/prompts/test_template_multipart_integration.py b/tests/unit/fast_agent/mcp/prompts/test_template_multipart_integration.py index 6a492bed6..f81047886 100644 --- a/tests/unit/fast_agent/mcp/prompts/test_template_multipart_integration.py +++ b/tests/unit/fast_agent/mcp/prompts/test_template_multipart_integration.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.mcp.prompt_serialization import ( load_delimited, diff --git a/tests/unit/fast_agent/mcp/test_content_helpers.py b/tests/unit/fast_agent/mcp/test_content_helpers.py index f3b38edaf..757fb2d7d 100644 --- a/tests/unit/fast_agent/mcp/test_content_helpers.py +++ b/tests/unit/fast_agent/mcp/test_content_helpers.py @@ -2,7 +2,7 @@ import base64 -from mcp.types import CallToolResult, ImageContent, TextContent +from mcp_types import CallToolResult, ImageContent, TextContent from fast_agent.mcp.helpers.content_helpers import ( audio_link, @@ -27,8 +27,8 @@ def test_canonicalize_tool_result_content_preserves_original_content_without_str ): image_data = base64.b64encode(b"fake-image").decode("utf-8") text_block = TextContent(type="text", text="hello") - image_block = ImageContent(type="image", data=image_data, mimeType="image/jpeg") - result = CallToolResult(content=[text_block, image_block], isError=False) + image_block = ImageContent(type="image", data=image_data, mime_type="image/jpeg") + result = CallToolResult(content=[text_block, image_block], is_error=False) canonical = canonicalize_tool_result_content_for_llm(result) @@ -41,12 +41,12 @@ def test_canonicalize_tool_result_content_prefers_structured_content_and_preserv None ): image_data = base64.b64encode(b"fake-image").decode("utf-8") - image_block = ImageContent(type="image", data=image_data, mimeType="image/jpeg") + image_block = ImageContent(type="image", data=image_data, mime_type="image/jpeg") result = CallToolResult( content=[TextContent(type="text", text="stale summary"), image_block], - isError=False, + is_error=False, ) - setattr(result, "structuredContent", {"z": 3, "a": 1}) + setattr(result, "structured_content", {"z": 3, "a": 1}) canonical = canonicalize_tool_result_content_for_llm(result) @@ -63,9 +63,9 @@ def test_canonicalize_tool_result_content_warns_for_multiple_text_blocks() -> No TextContent(type="text", text="first"), TextContent(type="text", text="second"), ], - isError=False, + is_error=False, ) - setattr(result, "structuredContent", {"fresh": True}) + setattr(result, "structured_content", {"fresh": True}) canonicalize_tool_result_content_for_llm(result, logger=logger, source="test.helper") @@ -78,9 +78,9 @@ def test_canonicalize_tool_result_content_warns_for_multiple_text_blocks() -> No def test_tool_result_text_for_llm_uses_structured_content_json() -> None: result = CallToolResult( content=[TextContent(type="text", text="stale summary")], - isError=False, + is_error=False, ) - setattr(result, "structuredContent", {"b": 2, "a": 1}) + setattr(result, "structured_content", {"b": 2, "a": 1}) text = tool_result_text_for_llm(result) @@ -88,12 +88,12 @@ def test_tool_result_text_for_llm_uses_structured_content_json() -> None: def test_typed_resource_links_use_default_mime_for_unknown_urls() -> None: - assert image_link("https://example.com/image").mimeType == "image/jpeg" - assert video_link("https://example.com/video").mimeType == "video/mp4" - assert audio_link("https://example.com/audio").mimeType == "audio/mpeg" + assert image_link("https://example.com/image").mime_type == "image/jpeg" + assert video_link("https://example.com/video").mime_type == "video/mp4" + assert audio_link("https://example.com/audio").mime_type == "audio/mpeg" def test_resource_link_mime_inference_preserves_query_value_case() -> None: link = resource_link("https://example.com/download?format=image%2FPNG") - assert link.mimeType == "image/png" + assert link.mime_type == "image/png" diff --git a/tests/unit/fast_agent/mcp/test_elicitation_handlers.py b/tests/unit/fast_agent/mcp/test_elicitation_handlers.py index 9811ad4a5..e742ce5ef 100644 --- a/tests/unit/fast_agent/mcp/test_elicitation_handlers.py +++ b/tests/unit/fast_agent/mcp/test_elicitation_handlers.py @@ -2,7 +2,7 @@ from typing import Any import pytest -from mcp.types import CallToolResult, ElicitRequestURLParams +from mcp_types import CallToolResult, ElicitRequestURLParams from fast_agent.human_input.types import HumanInputResponse from fast_agent.mcp.elicitation_handlers import ( @@ -142,7 +142,7 @@ async def test_forms_handler_defers_url_elicitation_to_result_payload(capsys) -> mode="url", message="Open browser to continue", url="https://example.com/continue", - elicitationId="form-url-1", + elicitation_id="form-url-1", ) result = await forms_elicitation_handler(context, params) @@ -151,7 +151,7 @@ async def test_forms_handler_defers_url_elicitation_to_result_payload(capsys) -> captured = capsys.readouterr() assert captured.out.strip() == "" - tool_result = CallToolResult(content=[], isError=False) + tool_result = CallToolResult(content=[], is_error=False) session._attach_pending_url_elicitation_payload_for_request( tool_result, request_method="tools/call", diff --git a/tests/unit/fast_agent/mcp/test_harness_app_server.py b/tests/unit/fast_agent/mcp/test_harness_app_server.py index 069085654..a896bbf5b 100644 --- a/tests/unit/fast_agent/mcp/test_harness_app_server.py +++ b/tests/unit/fast_agent/mcp/test_harness_app_server.py @@ -6,7 +6,7 @@ import pytest from fastmcp import FastMCP -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.core.agent_app import AgentApp from fast_agent.core.fastagent import AgentInstance diff --git a/tests/unit/fast_agent/mcp/test_hf_token_verifier.py b/tests/unit/fast_agent/mcp/test_hf_token_verifier.py index 59e9f8aac..fce145aab 100644 --- a/tests/unit/fast_agent/mcp/test_hf_token_verifier.py +++ b/tests/unit/fast_agent/mcp/test_hf_token_verifier.py @@ -1,6 +1,6 @@ from __future__ import annotations -import httpx +import httpx2 as httpx import pytest from fastmcp.server.auth.providers.huggingface import ( HUGGINGFACE_USERINFO_ENDPOINT, diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py index 36e658510..ad3d2f8c3 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py @@ -1,11 +1,16 @@ from __future__ import annotations -from datetime import timedelta from types import SimpleNamespace from typing import Any, cast import pytest -from mcp.types import CallToolRequest, CallToolResult, ClientRequest, TextContent +from mcp_types import ( + CallToolRequest, + CallToolResult, + ClientRequest, + RequestParamsMeta, + TextContent, +) from fast_agent.llm.fastagent_llm import _mcp_metadata_var from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession @@ -24,6 +29,13 @@ async def read_resource(self, **kwargs: Any) -> Any: self.last_kwargs = dict(kwargs) return "ok-read" + call_tool_complete = call_tool + read_resource_complete = read_resource + + async def get_prompt_complete(self, **kwargs: Any) -> Any: + self.last_kwargs = dict(kwargs) + return "ok-prompt" + class _FakeConnectionManager: def __init__(self, session: _RecordingSession) -> None: @@ -35,9 +47,12 @@ async def get_server(self, server_name: str, client_session_factory) -> SimpleNa class _RawCallToolSession(MCPAgentClientSession): + _call_tool_adapter = object() + def __init__(self) -> None: self.last_request: ClientRequest | None = None self.last_timeout = None + self._tool_output_schemas = {"legacy_tool": None} async def send_request( self, @@ -56,24 +71,24 @@ async def send_request( @pytest.mark.asyncio async def test_client_session_call_tool_uses_raw_request_path_with_meta() -> None: session = _RawCallToolSession() - metadata = {"trace": {"id": "abc"}} + metadata = cast("RequestParamsMeta", {"trace": {"id": "abc"}}) result = await session.call_tool( name="legacy_tool", arguments={"value": 1}, - read_timeout_seconds=timedelta(seconds=3), + read_timeout_seconds=3, meta=metadata, ) assert result.content == [TextContent(type="text", text="legacy result")] - assert session.last_timeout == timedelta(seconds=3) + assert session.last_timeout == 3 assert session.last_request is not None - request = cast("CallToolRequest", session.last_request.root) + request = cast("CallToolRequest", session.last_request) assert request.method == "tools/call" assert request.params.name == "legacy_tool" assert request.params.arguments == {"value": 1} assert request.params.meta is not None - assert request.params.meta.model_dump(exclude_none=True) == metadata + assert request.params.meta == metadata @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py index 4f334bc67..54bfa76b8 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, cast import pytest -from mcp.shared.exceptions import McpError -from mcp.types import ( +from mcp.shared.exceptions import MCPError as McpError +from mcp_types import ( CallToolResult, ErrorData, Implementation, @@ -34,6 +34,8 @@ from fast_agent.mcp_server_registry import ServerRegistry if TYPE_CHECKING: + from mcp.client.session import ClientSession + from fast_agent.mcp.mcp_connection_manager import MCPConnectionManager @@ -60,11 +62,19 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def initialize(self): self.initialized = True return InitializeResult( - protocolVersion="2025-03-26", + protocol_version="2025-03-26", capabilities=ServerCapabilities(tools=ToolsCapability()), - serverInfo=Implementation(name="stub", version="0.1"), + server_info=Implementation(name="stub", version="0.1"), ) + async def send_discover(self, version: str): + del version + raise McpError(-32601, "Method not found") + + @property + def server_capabilities(self) -> ServerCapabilities | None: + return ServerCapabilities(tools=ToolsCapability()) if self.initialized else None + def _make_stub_aggregator( context: Context, @@ -279,7 +289,7 @@ def __init__(self, trigger_oauth: bool | None) -> None: async def list_tools(self) -> ListToolsResult: if self._trigger_oauth is not True: raise RuntimeError("401 Unauthorized") - return ListToolsResult(tools=[Tool(name="echo", inputSchema={"type": "object"})]) + return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) @asynccontextmanager async def _fake_gen_client( @@ -337,6 +347,14 @@ async def call_tool(self, **kwargs): # noqa: ANN003 del kwargs return CallToolResult(content=[TextContent(type="text", text="ok")]) + call_tool_complete = call_tool + + async def read_resource_complete(self, **kwargs): # noqa: ANN003 + raise AssertionError(kwargs) + + async def get_prompt_complete(self, **kwargs): # noqa: ANN003 + raise AssertionError(kwargs) + gen_client_calls: list[str] = [] @asynccontextmanager @@ -370,6 +388,33 @@ async def _fake_gen_client( assert gen_client_calls == ["hf"] +@pytest.mark.asyncio +async def test_execute_session_method_rejects_session_without_completed_mrtr_operations() -> None: + aggregator = MCPAggregator( + server_names=[], + connection_persistence=False, + context=_build_context({}), + ) + + class _IncompleteSession: + async def call_tool(self, **kwargs): # noqa: ANN003 + raise AssertionError(kwargs) + + with pytest.raises( + TypeError, + match="MCP session factory must provide completed MRTR operations", + ): + await aggregator._execute_session_method( + cast("ClientSession", _IncompleteSession()), + server_name="alpha", + operation_name="echo", + method_name="call_tool", + method_args={"name": "echo", "arguments": {}}, + error_factory=None, + progress_callback=None, + ) + + # --------------------------------------------------------------------------- # get_capabilities (non-persistent path) # --------------------------------------------------------------------------- @@ -388,11 +433,7 @@ async def test_get_capabilities_nonpersistent_returns_real_capabilities( @asynccontextmanager async def _fake_initialize_server(self, server_name, client_session_factory=None, trigger_oauth=None): del trigger_oauth - self._init_results[server_name] = InitializeResult( - protocolVersion="2025-03-26", - capabilities=expected_caps, - serverInfo=Implementation(name="stub", version="0.1"), - ) + self._capabilities[server_name] = expected_caps yield _DummySession() monkeypatch.setattr( @@ -425,11 +466,7 @@ async def _counting_initialize(self, server_name, client_session_factory=None, t del trigger_oauth nonlocal init_count init_count += 1 - self._init_results[server_name] = InitializeResult( - protocolVersion="2025-03-26", - capabilities=expected_caps, - serverInfo=Implementation(name="stub", version="0.1"), - ) + self._capabilities[server_name] = expected_caps yield _DummySession() monkeypatch.setattr( @@ -496,7 +533,9 @@ def _exploding_initialize(self, server_name, client_session_factory=None, trigge def _make_mcp_error_none_code(message: str) -> McpError: """Build an McpError whose error code is None (simulates servers that omit it).""" error_data = ErrorData.model_construct(code=None, message=message) - return McpError(error_data) + error = McpError(0, message) + error.error = error_data + return error @pytest.mark.asyncio @@ -515,7 +554,7 @@ async def test_fetch_server_tools_returns_empty_for_mcp_error() -> None: aggregator = _make_stub_aggregator( _build_context({}), "no-tools", - execute_error=McpError( + execute_error=McpError.from_error_data( ErrorData(code=METHOD_NOT_FOUND_ERROR_CODE, message="Method not found") ), ) @@ -552,7 +591,7 @@ async def test_fetch_server_tools_reraises_non_probe_mcp_error() -> None: aggregator = _make_stub_aggregator( _build_context({}), "bad-req", - execute_error=McpError(ErrorData(code=-32600, message="Invalid request")), + execute_error=McpError.from_error_data(ErrorData(code=-32600, message="Invalid request")), ) with pytest.raises(McpError): await aggregator._fetch_server_tools("bad-req") @@ -566,8 +605,8 @@ async def test_fetch_server_tools_nonpersistent_success() -> None: supports_tools=True, execute_result=ListToolsResult( tools=[ - Tool(name="read_file", inputSchema={"type": "object"}), - Tool(name="write_file", inputSchema={"type": "object"}), + Tool(name="read_file", input_schema={"type": "object"}), + Tool(name="write_file", input_schema={"type": "object"}), ] ), ) @@ -581,7 +620,7 @@ async def test_fetch_server_tools_reraises_mcp_error_when_tools_advertised() -> _build_context({}), "broken", supports_tools=True, - execute_error=McpError(ErrorData(code=-32600, message="Invalid request")), + execute_error=McpError.from_error_data(ErrorData(code=-32600, message="Invalid request")), ) with pytest.raises(McpError): await aggregator._fetch_server_tools("broken") @@ -634,13 +673,13 @@ def test_is_capability_probe_error_with_not_implemented_error() -> None: def test_is_capability_probe_error_with_method_not_found_code() -> None: - exc = McpError(ErrorData(code=METHOD_NOT_FOUND_ERROR_CODE, message="Method not found")) + exc = McpError.from_error_data(ErrorData(code=METHOD_NOT_FOUND_ERROR_CODE, message="Method not found")) assert _is_capability_probe_error(exc) is True def test_is_capability_probe_error_with_method_not_found_message_no_code() -> None: """Message fallback only triggers when the server omitted the error code.""" - exc = McpError(ErrorData(code=0, message="Method not found on server")) + exc = McpError.from_error_data(ErrorData(code=0, message="Method not found on server")) # code=0 is truthy but not None — message fallback should NOT trigger assert _is_capability_probe_error(exc) is False @@ -652,10 +691,10 @@ def test_is_capability_probe_error_with_method_not_found_message_no_code() -> No def test_is_capability_probe_error_rejects_infrastructure_errors() -> None: assert _is_capability_probe_error(RuntimeError("connection lost")) is False assert _is_capability_probe_error(AttributeError("no such attr")) is False - exc = McpError(ErrorData(code=-32600, message="Invalid request")) + exc = McpError.from_error_data(ErrorData(code=-32600, message="Invalid request")) assert _is_capability_probe_error(exc) is False # Different code + "method not found" in message should NOT match - exc2 = McpError(ErrorData(code=-32000, message="Method not found on server")) + exc2 = McpError.from_error_data(ErrorData(code=-32000, message="Method not found on server")) assert _is_capability_probe_error(exc2) is False @@ -675,11 +714,7 @@ async def test_detach_server_clears_capabilities_cache(monkeypatch) -> None: @asynccontextmanager async def _fake_initialize_server(self, server_name, client_session_factory=None, trigger_oauth=None): del trigger_oauth - self._init_results[server_name] = InitializeResult( - protocolVersion="2025-03-26", - capabilities=expected_caps, - serverInfo=Implementation(name="stub", version="0.1"), - ) + self._capabilities[server_name] = expected_caps yield _DummySession() monkeypatch.setattr( @@ -748,11 +783,7 @@ async def initialize_server( del client_session_factory, trigger_oauth capabilities = capability_generations[min(self.initialize_count, 1)] self.initialize_count += 1 - self._init_results[server_name] = InitializeResult( - protocolVersion="2025-03-26", - capabilities=capabilities, - serverInfo=Implementation(name="stub", version="0.1"), - ) + self._capabilities[server_name] = capabilities yield _DummySession() registry = _SequencedRegistry() @@ -774,9 +805,9 @@ async def _execute_on_server( if method_name == "list_tools": if capabilities and capabilities.tools: return ListToolsResult( - tools=[Tool(name="echo", inputSchema={"type": "object"})] + tools=[Tool(name="echo", input_schema={"type": "object"})] ) - raise McpError( + raise McpError.from_error_data( ErrorData(code=METHOD_NOT_FOUND_ERROR_CODE, message="Method not found") ) if method_name == "list_prompts": diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py index 61460636d..c9a9968f3 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import pytest -from mcp.types import CompleteResult, Completion, ResourceTemplate +from mcp_types import CompleteResult, Completion, ResourceTemplate import fast_agent.mcp.mcp_aggregator as aggregator_module from fast_agent.context import Context @@ -40,7 +40,7 @@ async def _execute_on_server( del operation_type, operation_name, method_args, error_factory, progress_callback assert method_name == "list_resource_templates" return SimpleNamespace( - resourceTemplates=[ResourceTemplate(name="repo", uriTemplate="repo://{id}")] + resource_templates=[ResourceTemplate(name="repo", uri_template="repo://{id}")] ) aggregator = _TemplatesAggregator( @@ -52,7 +52,7 @@ async def _execute_on_server( result = await aggregator.list_resource_templates("demo") assert list(result.keys()) == ["demo"] - assert result["demo"][0].uriTemplate == "repo://{id}" + assert result["demo"][0].uri_template == "repo://{id}" @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_runtime_attach.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_runtime_attach.py index 139fd983f..b5fb28b52 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_runtime_attach.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_runtime_attach.py @@ -1,14 +1,13 @@ from types import SimpleNamespace import pytest -from mcp.types import ( +from mcp_types import ( ListToolsResult, ReadResourceResult, ServerCapabilities, TextResourceContents, Tool, ) -from pydantic import AnyUrl from fast_agent.config import MCPServerSettings from fast_agent.context import Context @@ -87,7 +86,7 @@ async def test_detach_server_removes_runtime_indexes() -> None: ) namespaced_tool = NamespacedTool( - tool=Tool(name="demo", inputSchema={"type": "object"}), + tool=Tool(name="demo", input_schema={"type": "object"}), server_name="alpha", namespaced_tool_name="alpha.demo", ) @@ -182,7 +181,7 @@ async def _execute_on_server( error_factory, progress_callback, ) - return ListToolsResult(tools=[Tool(name="echo", inputSchema={"type": "object"})]) + return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) aggregator = _FallbackAggregator( server_names=["alpha"], @@ -222,7 +221,7 @@ async def _execute_on_server( progress_callback, ) if method_name == "list_tools": - return ListToolsResult(tools=[Tool(name="echo", inputSchema={"type": "object"})]) + return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) if method_name == "list_prompts": return SimpleNamespace(prompts=[SimpleNamespace(name="demo-prompt")]) raise AssertionError(f"Unexpected MCP method: {method_name}") @@ -319,12 +318,12 @@ async def _execute_on_server( if method_name == "list_prompts": return SimpleNamespace(prompts=[]) if method_name == "read_resource": - assert method_args == {"uri": AnyUrl(index_uri)} + assert method_args == {"uri": index_uri} return ReadResourceResult( contents=[ TextResourceContents( - uri=AnyUrl(index_uri), - mimeType="application/json", + uri=index_uri, + mime_type="application/json", text=index_text, ) ] diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_server_instructions.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_server_instructions.py index 25590f8e1..37244d60b 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_server_instructions.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_server_instructions.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock -from mcp.types import Tool +from mcp_types import Tool PROJECT_ROOT = Path(__file__).resolve().parents[4] MODULE_PATH = PROJECT_ROOT / "src" / "fast_agent" / "mcp" / "mcp_aggregator.py" @@ -53,7 +53,7 @@ def test_get_server_instructions_does_not_implicitly_connect() -> None: aggregator._namespaced_tool_map = { "huggingface.tool_a": NamespacedTool( - tool=Tool(name="tool_a", inputSchema={"type": "object"}), + tool=Tool(name="tool_a", input_schema={"type": "object"}), server_name="huggingface", namespaced_tool_name="huggingface.tool_a", ) diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py index 1a9950fea..71748c7bc 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py @@ -8,7 +8,7 @@ from typing import Any from unittest.mock import AsyncMock -from mcp.types import Tool +from mcp_types import Tool PROJECT_ROOT = Path(__file__).resolve().parents[4] MODULE_PATH = PROJECT_ROOT / "src" / "fast_agent" / "mcp" / "mcp_aggregator.py" @@ -89,7 +89,7 @@ def test_skybridge_detection_marks_valid_resources() -> None: return_value=[SimpleNamespace(uri="ui://component/app")] ) aggregator._get_resource_from_server = AsyncMock( - return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=SKYBRIDGE_MIME_TYPE)]) + return_value=SimpleNamespace(contents=[SimpleNamespace(mime_type=SKYBRIDGE_MIME_TYPE)]) ) server_name, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) @@ -129,7 +129,7 @@ def test_mcp_app_detection_marks_valid_resources() -> None: return_value=[SimpleNamespace(uri="ui://component/app")] ) aggregator._get_resource_from_server = AsyncMock( - return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=MCP_APP_MIME_TYPE)]) + return_value=SimpleNamespace(contents=[SimpleNamespace(mime_type=MCP_APP_MIME_TYPE)]) ) _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) @@ -163,7 +163,7 @@ def test_mcp_app_detection_supports_legacy_flat_resource_uri() -> None: return_value=[SimpleNamespace(uri="ui://component/app")] ) aggregator._get_resource_from_server = AsyncMock( - return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=MCP_APP_MIME_TYPE)]) + return_value=SimpleNamespace(contents=[SimpleNamespace(mime_type=MCP_APP_MIME_TYPE)]) ) _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) @@ -191,7 +191,7 @@ def test_mcp_app_detection_warns_on_skybridge_mime() -> None: return_value=[SimpleNamespace(uri="ui://component/app")] ) aggregator._get_resource_from_server = AsyncMock( - return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=SKYBRIDGE_MIME_TYPE)]) + return_value=SimpleNamespace(contents=[SimpleNamespace(mime_type=SKYBRIDGE_MIME_TYPE)]) ) _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) @@ -220,7 +220,7 @@ def test_skybridge_detection_warns_on_invalid_mime() -> None: return_value=[SimpleNamespace(uri="ui://component/app")] ) aggregator._get_resource_from_server = AsyncMock( - return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType="text/html")]) + return_value=SimpleNamespace(contents=[SimpleNamespace(mime_type="text/html")]) ) _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) @@ -392,7 +392,7 @@ def test_skybridge_resource_without_tool_warns() -> None: return_value=[SimpleNamespace(uri="ui://component/app")] ) aggregator._get_resource_from_server = AsyncMock( - return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=SKYBRIDGE_MIME_TYPE)]) + return_value=SimpleNamespace(contents=[SimpleNamespace(mime_type=SKYBRIDGE_MIME_TYPE)]) ) _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) diff --git a/tests/unit/fast_agent/mcp/test_mcp_prompt_resource_like.py b/tests/unit/fast_agent/mcp/test_mcp_prompt_resource_like.py index 7092396db..e267bb520 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_prompt_resource_like.py +++ b/tests/unit/fast_agent/mcp/test_mcp_prompt_resource_like.py @@ -2,8 +2,7 @@ from dataclasses import dataclass -from mcp.types import EmbeddedResource, TextResourceContents -from pydantic import AnyUrl +from mcp_types import EmbeddedResource, TextResourceContents from fast_agent.mcp.mcp_content import MCPPrompt @@ -16,9 +15,9 @@ class _ResourceLike: def test_mcp_prompt_accepts_resource_like_object_without_dynamic_probe() -> None: resource = TextResourceContents( - uri=AnyUrl("file:///tmp/example.txt"), + uri="file:///tmp/example.txt", text="example", - mimeType="text/plain", + mime_type="text/plain", ) messages = MCPPrompt(_ResourceLike(type="resource", resource=resource)) diff --git a/tests/unit/fast_agent/mcp/test_oauth_provider_urls.py b/tests/unit/fast_agent/mcp/test_oauth_provider_urls.py index e6c2c8752..4b663f5a0 100644 --- a/tests/unit/fast_agent/mcp/test_oauth_provider_urls.py +++ b/tests/unit/fast_agent/mcp/test_oauth_provider_urls.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 as httpx import pytest from mcp.shared.auth import ProtectedResourceMetadata from pydantic import AnyHttpUrl diff --git a/tests/unit/fast_agent/mcp/test_prompt_format_utils.py b/tests/unit/fast_agent/mcp/test_prompt_format_utils.py index 929de9e38..879efe9ae 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_format_utils.py +++ b/tests/unit/fast_agent/mcp/test_prompt_format_utils.py @@ -7,13 +7,12 @@ from pathlib import Path import pytest -from mcp.types import ( +from mcp_types import ( EmbeddedResource, ImageContent, TextContent, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.mcp.prompt_message_extended import PromptMessageExtended from fast_agent.mcp.prompt_serialization import ( @@ -53,8 +52,8 @@ def test_multipart_with_resources_to_delimited(self): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://code.py"), - mimeType="text/x-python", + uri="resource://code.py", + mime_type="text/x-python", text='print("Hello, World!")', ), ), @@ -70,8 +69,8 @@ def test_multipart_with_resources_to_delimited(self): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://improved_code.py"), - mimeType="text/x-python", + uri="resource://improved_code.py", + mime_type="text/x-python", text='def main():\n print("Hello, World!")\n\nif __name__ == "__main__":\n main()', ), ), @@ -190,7 +189,7 @@ def test_delimited_with_resources_to_extended(self): resource = _resource(messages[0].content[1]) assert resource.type == "resource" assert str(resource.resource.uri) == "resource://styles.css" - assert resource.resource.mimeType == "text/css" + assert resource.resource.mime_type == "text/css" assert _resource_text(resource.resource) == "body { color: black; }" assert messages[1].role == "assistant" @@ -200,7 +199,7 @@ def test_delimited_with_resources_to_extended(self): resource = _resource(messages[1].content[1]) assert resource.type == "resource" assert str(resource.resource.uri) == "resource://improved_styles.css" - assert resource.resource.mimeType == "text/css" + assert resource.resource.mime_type == "text/css" assert _resource_text(resource.resource) == "body { color: #000; }" def test_multiple_resources_in_one_message(self): @@ -213,16 +212,16 @@ def test_multiple_resources_in_one_message(self): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://data1.csv"), - mimeType="text/csv", + uri="resource://data1.csv", + mime_type="text/csv", text="id,name,value\n1,A,10\n2,B,20", ), ), EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://data2.csv"), - mimeType="text/csv", + uri="resource://data2.csv", + mime_type="text/csv", text="id,name,value\n3,C,30\n4,D,40", ), ), @@ -270,12 +269,12 @@ def test_multiple_resources_in_one_message(self): # Verify resource content is preserved resource = _resource(messages[0].content[1]) assert str(resource.resource.uri) == "resource://data1.csv" - assert resource.resource.mimeType == "text/csv" + assert resource.resource.mime_type == "text/csv" assert "id,name,value" in _resource_text(resource.resource) resource = _resource(messages[0].content[2]) assert str(resource.resource.uri) == "resource://data2.csv" - assert resource.resource.mimeType == "text/csv" + assert resource.resource.mime_type == "text/csv" assert "id,name,value" in _resource_text(resource.resource) def test_image_handling(self): @@ -285,7 +284,7 @@ def test_image_handling(self): role="user", content=[ TextContent(type="text", text="Look at this image:"), - ImageContent(type="image", data="base64EncodedImageData", mimeType="image/png"), + ImageContent(type="image", data="base64EncodedImageData", mime_type="image/png"), ], ) @@ -343,8 +342,8 @@ def test_save_and_load_with_resources(self, temp_resource_file): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://config.json"), - mimeType="application/json", + uri="resource://config.json", + mime_type="application/json", text='{"key": "value"}', ), ), @@ -378,16 +377,16 @@ def test_round_trip_with_mime_types(self): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://script.js"), - mimeType="application/javascript", + uri="resource://script.js", + mime_type="application/javascript", text="function hello() { return 'Hello!'; }", ), ), EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://style.css"), - mimeType="text/css", + uri="resource://style.css", + mime_type="text/css", text="body { color: blue; }", ), ), diff --git a/tests/unit/fast_agent/mcp/test_prompt_load_usage_rehydration.py b/tests/unit/fast_agent/mcp/test_prompt_load_usage_rehydration.py index 1534197f5..67c9ea7ac 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_load_usage_rehydration.py +++ b/tests/unit/fast_agent/mcp/test_prompt_load_usage_rehydration.py @@ -3,7 +3,7 @@ import json import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent diff --git a/tests/unit/fast_agent/mcp/test_prompt_message_multipart.py b/tests/unit/fast_agent/mcp/test_prompt_message_multipart.py index 39ea6ff8f..809641922 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_message_multipart.py +++ b/tests/unit/fast_agent/mcp/test_prompt_message_multipart.py @@ -2,7 +2,7 @@ Unit tests for the PromptMessageExtended class. """ -from mcp.types import ( +from mcp_types import ( GetPromptResult, ImageContent, PromptMessage, @@ -80,7 +80,7 @@ def test_from_prompt_messages_with_mixed_content_types(self): """Test converting messages with mixed content types (text and image).""" # Create a message with an image content image_content = ImageContent( - type="image", data="base64_encoded_image_data", mimeType="image/png" + type="image", data="base64_encoded_image_data", mime_type="image/png" ) messages = [ @@ -102,7 +102,7 @@ def test_from_prompt_messages_with_mixed_content_types(self): image_block = _image(result[0].content[1]) assert image_block.type == "image" assert image_block.data == "base64_encoded_image_data" - assert image_block.mimeType == "image/png" + assert image_block.mime_type == "image/png" def test_to_prompt_messages(self): """Test converting a PromptMessageExtended back to PromptMessages.""" diff --git a/tests/unit/fast_agent/mcp/test_prompt_metadata.py b/tests/unit/fast_agent/mcp/test_prompt_metadata.py index d45811bed..6d5371623 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_metadata.py +++ b/tests/unit/fast_agent/mcp/test_prompt_metadata.py @@ -1,4 +1,4 @@ -from mcp.types import GetPromptResult +from mcp_types import GetPromptResult from fast_agent.mcp.prompt_metadata import ( prompt_arguments, diff --git a/tests/unit/fast_agent/mcp/test_prompt_multipart_conversion.py b/tests/unit/fast_agent/mcp/test_prompt_multipart_conversion.py index 42c2c66b8..483d9d526 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_multipart_conversion.py +++ b/tests/unit/fast_agent/mcp/test_prompt_multipart_conversion.py @@ -6,7 +6,7 @@ import tempfile from pathlib import Path -from mcp.types import PromptMessage, TextContent +from mcp_types import PromptMessage, TextContent from fast_agent.mcp.prompt_message_extended import PromptMessageExtended from fast_agent.mcp.prompts.prompt_load import create_messages_with_resources, load_prompt diff --git a/tests/unit/fast_agent/mcp/test_prompt_render.py b/tests/unit/fast_agent/mcp/test_prompt_render.py index 94299171e..af592f764 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_render.py +++ b/tests/unit/fast_agent/mcp/test_prompt_render.py @@ -2,19 +2,19 @@ Unit tests for the prompt rendering utilities. """ -from mcp.types import BlobResourceContents, EmbeddedResource, TextContent, TextResourceContents +from mcp_types import BlobResourceContents, EmbeddedResource, TextContent, TextResourceContents from fast_agent.mcp.prompt_message_extended import PromptMessageExtended from fast_agent.mcp.prompt_render import render_content_blocks -from fast_agent.mcp.resource_utils import create_image_content, to_any_url +from fast_agent.mcp.resource_utils import create_image_content def create_blob_resource(resource_path: str, content: str, mime_type: str) -> EmbeddedResource: return EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=to_any_url(resource_path), - mimeType=mime_type, + uri=resource_path, + mime_type=mime_type, blob=content, ), ) @@ -24,8 +24,8 @@ def create_text_resource(resource_path: str, content: str, mime_type: str) -> Em return EmbeddedResource( type="resource", resource=TextResourceContents( - uri=to_any_url(resource_path), - mimeType=mime_type, + uri=resource_path, + mime_type=mime_type, text=content, ), ) diff --git a/tests/unit/fast_agent/mcp/test_prompt_serialization.py b/tests/unit/fast_agent/mcp/test_prompt_serialization.py index 02d5182aa..85ada3e69 100644 --- a/tests/unit/fast_agent/mcp/test_prompt_serialization.py +++ b/tests/unit/fast_agent/mcp/test_prompt_serialization.py @@ -5,8 +5,7 @@ from datetime import datetime, timezone import pytest -from mcp.types import EmbeddedResource, ImageContent, TextContent, TextResourceContents -from pydantic import AnyUrl +from mcp_types import EmbeddedResource, ImageContent, TextContent, TextResourceContents from fast_agent.core.exceptions import AgentConfigError from fast_agent.mcp.prompt_message_extended import PromptMessageExtended @@ -34,8 +33,8 @@ def test_json_serialization_and_deserialization(self): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://data.json"), - mimeType="application/json", + uri="resource://data.json", + mime_type="application/json", text='{"key": "value"}', ), ), @@ -45,7 +44,7 @@ def test_json_serialization_and_deserialization(self): role="assistant", content=[ TextContent(type="text", text="I've processed your resource."), - ImageContent(type="image", data="base64EncodedImage", mimeType="image/jpeg"), + ImageContent(type="image", data="base64EncodedImage", mime_type="image/jpeg"), ], ), ] @@ -81,7 +80,7 @@ def test_json_serialization_and_deserialization(self): resource = resource_block.resource assert isinstance(resource, TextResourceContents) assert str(resource.uri) == "resource://data.json" - assert resource.mimeType == "application/json" + assert resource.mime_type == "application/json" assert resource.text == '{"key": "value"}' # Check second message @@ -94,7 +93,7 @@ def test_json_serialization_and_deserialization(self): assert isinstance(image_block, ImageContent) assert image_block.type == "image" assert image_block.data == "base64EncodedImage" - assert image_block.mimeType == "image/jpeg" + assert image_block.mime_type == "image/jpeg" def test_enhanced_json_round_trips_assistant_phase(self): original_messages = [ @@ -172,8 +171,8 @@ def test_multipart_with_resources_to_delimited_format(self): EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource://example.py"), - mimeType="text/x-python", + uri="resource://example.py", + mime_type="text/x-python", text="def hello():\n print('Hello, world!')", ), ), diff --git a/tests/unit/fast_agent/mcp/test_sampling.py b/tests/unit/fast_agent/mcp/test_sampling.py index ecbae6ac1..c74030c5e 100644 --- a/tests/unit/fast_agent/mcp/test_sampling.py +++ b/tests/unit/fast_agent/mcp/test_sampling.py @@ -1,4 +1,4 @@ -from mcp.types import CreateMessageRequestParams, SamplingMessage, TextContent +from mcp_types import CreateMessageRequestParams, SamplingMessage, TextContent from fast_agent.mcp.sampling import sampling_agent_config @@ -7,9 +7,9 @@ def test_build_sampling_agent_config_with_system_prompt(): """Test building AgentConfig with system prompt from params""" # Create params with system prompt params = CreateMessageRequestParams( - maxTokens=1024, + max_tokens=1024, messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], - systemPrompt="Custom system instruction", + system_prompt="Custom system instruction", ) # Build config @@ -36,9 +36,9 @@ def test_build_sampling_agent_config_empty_system_prompt(): """Test building AgentConfig with empty system prompt""" # Create params with empty system prompt params = CreateMessageRequestParams( - maxTokens=512, + max_tokens=512, messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], - systemPrompt="", + system_prompt="", ) # Build config diff --git a/tests/unit/fast_agent/mcp/test_server_session_terminated.py b/tests/unit/fast_agent/mcp/test_server_session_terminated.py index 44f1d3bee..f94fa475a 100644 --- a/tests/unit/fast_agent/mcp/test_server_session_terminated.py +++ b/tests/unit/fast_agent/mcp/test_server_session_terminated.py @@ -2,8 +2,8 @@ Tests for server session termination handling and reconnection functionality. """ -from mcp.shared.exceptions import McpError -from mcp.types import ErrorData +from mcp.shared.exceptions import MCPError as McpError +from mcp_types import ErrorData from fast_agent.config import MCPServerSettings from fast_agent.core.exceptions import FastAgentError, ServerSessionTerminatedError @@ -38,12 +38,12 @@ def _make_session(self): def test_detects_mcp_error_code_32600(self): """Detects McpError with code 32600.""" - error = McpError(ErrorData(code=32600, message="Session terminated")) + error = McpError.from_error_data(ErrorData(code=32600, message="Session terminated")) assert self._make_session()._is_session_terminated_error(error) is True def test_ignores_different_error_codes(self): """Ignores McpError with different codes.""" - error = McpError(ErrorData(code=-32601, message="Method not found")) + error = McpError.from_error_data(ErrorData(code=-32601, message="Method not found")) assert self._make_session()._is_session_terminated_error(error) is False def test_ignores_non_mcp_errors(self): diff --git a/tests/unit/fast_agent/mcp/test_sse_tracking.py b/tests/unit/fast_agent/mcp/test_sse_tracking.py deleted file mode 100644 index c99850c96..000000000 --- a/tests/unit/fast_agent/mcp/test_sse_tracking.py +++ /dev/null @@ -1,19 +0,0 @@ -from fast_agent.mcp.sse_tracking import _same_origin - - -def test_same_origin_normalizes_hostname_case() -> None: - assert _same_origin("https://Example.com/sse", "https://example.com/messages") - - -def test_same_origin_normalizes_scheme_case() -> None: - assert _same_origin("HTTPS://example.com:443/sse", "https://example.com/messages") - - -def test_same_origin_treats_default_ports_as_equivalent() -> None: - assert _same_origin("https://example.com:443/sse", "https://example.com/messages") - assert _same_origin("http://example.com/sse", "http://example.com:80/messages") - - -def test_same_origin_rejects_different_ports_and_hosts() -> None: - assert not _same_origin("https://example.com/sse", "https://example.com:8443/messages") - assert not _same_origin("https://example.com/sse", "https://other.example.com/messages") diff --git a/tests/unit/fast_agent/mcp/test_streamable_http_tracking.py b/tests/unit/fast_agent/mcp/test_streamable_http_tracking.py deleted file mode 100644 index 42a5d9484..000000000 --- a/tests/unit/fast_agent/mcp/test_streamable_http_tracking.py +++ /dev/null @@ -1,183 +0,0 @@ -import logging -from typing import TYPE_CHECKING, cast - -import anyio -import pytest -from mcp.client.streamable_http import MAX_RECONNECTION_ATTEMPTS -from mcp.shared.message import SessionMessage - -from fast_agent.mcp.streamable_http_tracking import ChannelTrackingStreamableHTTPTransport - -if TYPE_CHECKING: - import httpx - -pytestmark = pytest.mark.asyncio - - -class _Response: - def __init__(self, status_code: int) -> None: - self.status_code = status_code - - -class _Client: - def __init__(self, status_code: int) -> None: - self.status_code = status_code - - async def delete(self, url: str, headers: dict[str, str] | None = None) -> _Response: - del url, headers - return _Response(self.status_code) - - -class _FailingClient: - async def delete(self, url: str, headers: dict[str, str] | None = None) -> _Response: - del url, headers - raise RuntimeError("network down") - - -class _FakeEventSourceResponse: - def raise_for_status(self) -> None: - return None - - -class _DroppedSSEIterator: - def __aiter__(self): - return self - - async def __anext__(self): - raise RuntimeError("stream dropped") - - -class _EmptySSEIterator: - def __aiter__(self): - return self - - async def __anext__(self): - raise StopAsyncIteration - - -class _DropEventSource: - response = _FakeEventSourceResponse() - - def aiter_sse(self): - return _DroppedSSEIterator() - - -class _StopEventSource: - def __init__(self, *, on_complete) -> None: - self.response = _FakeEventSourceResponse() - self._on_complete = on_complete - - def aiter_sse(self): - self._on_complete() - return _EmptySSEIterator() - - -class _ScriptedSSEConnection: - def __init__(self, mode: str, *, on_complete=None) -> None: - self._mode = mode - self._on_complete = on_complete - - async def __aenter__(self): - if self._mode == "drop": - return _DropEventSource() - if self._mode == "stop": - return _StopEventSource(on_complete=self._on_complete) - raise RuntimeError(f"Unknown scripted mode: {self._mode}") - - async def __aexit__(self, exc_type, exc, tb) -> bool: - del exc_type, exc, tb - return False - - -class _ScriptedTransport(ChannelTrackingStreamableHTTPTransport): - def __init__(self, script: list[str]) -> None: - super().__init__("https://example.com/mcp") - self._script = script - self.open_calls = 0 - - def _open_sse_connection(self, client, method: str, url: str, *, headers: dict[str, str]): - del client, method, url, headers - mode = self._script[self.open_calls] - self.open_calls += 1 - if mode == "stop": - return _ScriptedSSEConnection( - mode, on_complete=lambda: setattr(self, "session_id", None) - ) - return _ScriptedSSEConnection(mode) - - async def _sleep_before_reconnect(self, delay_ms: int) -> None: - del delay_ms - return - - -def _transport() -> ChannelTrackingStreamableHTTPTransport: - transport = ChannelTrackingStreamableHTTPTransport("https://example.com/mcp") - transport.session_id = "session-123" - return transport - - -@pytest.fixture -def _capture_transport_logger(caplog): - """Capture transport logs even when ``fast_agent`` logger propagation is disabled.""" - - target_logger = logging.getLogger("fast_agent.mcp.streamable_http_tracking") - original_level = target_logger.level - target_logger.addHandler(caplog.handler) - target_logger.setLevel(logging.WARNING) - - try: - yield - finally: - target_logger.removeHandler(caplog.handler) - target_logger.setLevel(original_level) - - -async def test_terminate_session_accepts_202_without_warning( - caplog, _capture_transport_logger -) -> None: - transport = _transport() - - await transport.terminate_session(cast("httpx.AsyncClient", _Client(202))) - - assert "Session termination failed" not in caplog.text - - -async def test_terminate_session_logs_warning_for_unexpected_status( - caplog, _capture_transport_logger -) -> None: - transport = _transport() - - await transport.terminate_session(cast("httpx.AsyncClient", _Client(500))) - - assert "Session termination failed: 500" in caplog.text - - -async def test_terminate_session_logs_warning_on_exception( - caplog, _capture_transport_logger -) -> None: - transport = _transport() - - await transport.terminate_session(cast("httpx.AsyncClient", _FailingClient())) - - assert "Session termination failed: network down" in caplog.text - - -async def test_get_stream_resets_error_counter_after_successful_retry() -> None: - if MAX_RECONNECTION_ATTEMPTS <= 1: - pytest.skip("Reconnect-attempt reset requires MAX_RECONNECTION_ATTEMPTS > 1") - - # Multiple stream drops should not exhaust the retry budget as long as each reconnect succeeds. - script = ["drop"] * (MAX_RECONNECTION_ATTEMPTS + 2) + ["stop"] - transport = _ScriptedTransport(script) - transport.session_id = "session-123" - read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception]( - 10 - ) - - try: - await transport.handle_get_stream(cast("httpx.AsyncClient", object()), read_stream_writer) - finally: - await read_stream_writer.aclose() - await read_stream.aclose() - - assert transport.open_calls == len(script) diff --git a/tests/unit/fast_agent/mcp/test_tool_result_truncation.py b/tests/unit/fast_agent/mcp/test_tool_result_truncation.py index 5e37f585e..6bd22ba44 100644 --- a/tests/unit/fast_agent/mcp/test_tool_result_truncation.py +++ b/tests/unit/fast_agent/mcp/test_tool_result_truncation.py @@ -1,11 +1,10 @@ -from mcp.types import ( +from mcp_types import ( CallToolResult, EmbeddedResource, ImageContent, TextContent, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.mcp.helpers.content_helpers import canonicalize_tool_result_content_for_llm from fast_agent.mcp.tool_result_truncation import truncate_tool_result_for_llm @@ -18,17 +17,17 @@ def test_tool_result_within_budget_is_unchanged() -> None: def test_tool_result_truncates_canonical_structured_content() -> None: - image = ImageContent(type="image", data="aGVsbG8=", mimeType="image/png") + image = ImageContent(type="image", data="aGVsbG8=", mime_type="image/png") result = CallToolResult( content=[TextContent(type="text", text="ignored"), image], - structuredContent={"value": "x" * 100}, + structured_content={"value": "x" * 100}, ) truncated = truncate_tool_result_for_llm(result, byte_limit=40) canonical = canonicalize_tool_result_content_for_llm(truncated) assert truncated is not result - assert truncated.structuredContent is None + assert truncated.structured_content is None assert len(canonical) == 2 assert isinstance(canonical[0], TextContent) assert canonical[0].text.startswith('{"value":"xxxxxxxxxx') @@ -59,9 +58,9 @@ def test_tool_result_truncates_embedded_text_resources() -> None: resource = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file:///large.txt"), + uri="file:///large.txt", text="x" * 100, - mimeType="text/plain", + mime_type="text/plain", ), ) result = CallToolResult(content=[resource]) diff --git a/tests/unit/fast_agent/mcp/test_transport_factory_validation.py b/tests/unit/fast_agent/mcp/test_transport_factory_validation.py index 8ee300814..434c5f0d3 100644 --- a/tests/unit/fast_agent/mcp/test_transport_factory_validation.py +++ b/tests/unit/fast_agent/mcp/test_transport_factory_validation.py @@ -79,11 +79,6 @@ def _fake_prepare_headers_and_auth(server_config, **kwargs): "fast_agent.mcp.mcp_connection_manager._prepare_headers_and_auth", _fake_prepare_headers_and_auth, ) - monkeypatch.setattr( - "fast_agent.mcp.mcp_connection_manager.tracking_sse_client", - lambda *args, **kwargs: object(), - ) - server_config = MCPServerSettings(transport="sse", url="http://example.com/sse") ctx = create_transport_context(server_name="test_server", config=server_config) @@ -105,11 +100,6 @@ def _fake_prepare_headers_and_auth(server_config, **kwargs): "fast_agent.mcp.mcp_connection_manager._prepare_headers_and_auth", _fake_prepare_headers_and_auth, ) - monkeypatch.setattr( - "fast_agent.mcp.mcp_connection_manager.tracking_sse_client", - lambda *args, **kwargs: object(), - ) - server_config = MCPServerSettings( transport="sse", url="http://example.com/sse", diff --git a/tests/unit/fast_agent/mcp/test_transport_tracking.py b/tests/unit/fast_agent/mcp/test_transport_tracking.py index ba00ec4fc..dd5eec831 100644 --- a/tests/unit/fast_agent/mcp/test_transport_tracking.py +++ b/tests/unit/fast_agent/mcp/test_transport_tracking.py @@ -1,5 +1,5 @@ import pytest -from mcp.types import ErrorData, JSONRPCError, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +from mcp_types import ErrorData, JSONRPCError, JSONRPCRequest, JSONRPCResponse from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName, TransportChannelMetrics @@ -8,7 +8,7 @@ def test_ping_response_not_counted_as_post_response(): metrics = TransportChannelMetrics() metrics.register_ping_request(1) - message = JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})) + message = JSONRPCResponse(jsonrpc="2.0", id=1, result={}) metrics.record_event( ChannelEvent( channel="post-json", @@ -31,28 +31,28 @@ def test_transport_message_counts_are_tallied_by_channel() -> None: ChannelEvent( channel="post-json", event_type="message", - message=JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call")), + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call"), ) ) metrics.record_event( ChannelEvent( channel="get", event_type="message", - message=JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})), + message=JSONRPCResponse(jsonrpc="2.0", id=1, result={}), ) ) metrics.record_event( ChannelEvent( channel="resumption", event_type="message", - message=JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/read")), + message=JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/read"), ) ) metrics.record_event( ChannelEvent( channel="stdio", event_type="message", - message=JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=2, result={})), + message=JSONRPCResponse(jsonrpc="2.0", id=2, result={}), ) ) @@ -178,7 +178,7 @@ def test_response_channel_ignores_requests_until_response_arrives() -> None: ChannelEvent( channel="post-json", event_type="message", - message=JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=42, method="tools/call")), + message=JSONRPCRequest(jsonrpc="2.0", id=42, method="tools/call"), ) ) @@ -188,7 +188,7 @@ def test_response_channel_ignores_requests_until_response_arrives() -> None: ChannelEvent( channel="get", event_type="message", - message=JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=42, result={})), + message=JSONRPCResponse(jsonrpc="2.0", id=42, result={}), ) ) @@ -202,12 +202,10 @@ def test_response_channel_records_error_response() -> None: ChannelEvent( channel="get", event_type="message", - message=JSONRPCMessage( - JSONRPCError( - jsonrpc="2.0", - id=7, - error=ErrorData(code=-32000, message="failed"), - ) + message=JSONRPCError( + jsonrpc="2.0", + id=7, + error=ErrorData(code=-32000, message="failed"), ), ) ) @@ -221,12 +219,10 @@ def test_response_channel_records_error_response() -> None: ) def test_ping_request_variants_are_classified_as_ping(method: str) -> None: metrics = TransportChannelMetrics() - message = JSONRPCMessage( - JSONRPCRequest( - jsonrpc="2.0", - id=1, - method=method, - ) + message = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method=method, ) metrics.record_event( diff --git a/tests/unit/fast_agent/mcp/test_ui_mixin.py b/tests/unit/fast_agent/mcp/test_ui_mixin.py index f2a0d6244..b73064b07 100644 --- a/tests/unit/fast_agent/mcp/test_ui_mixin.py +++ b/tests/unit/fast_agent/mcp/test_ui_mixin.py @@ -1,6 +1,5 @@ import pytest -from mcp.types import CallToolResult, EmbeddedResource, TextContent, TextResourceContents -from pydantic import AnyUrl +from mcp_types import CallToolResult, EmbeddedResource, TextContent, TextResourceContents from rich.text import Text from fast_agent.agents.agent_types import AgentConfig @@ -107,7 +106,7 @@ def create_ui_resource(uri: str = "ui://test/component", text: str = "Test """Helper to create a UI embedded resource.""" return EmbeddedResource( type="resource", - resource=TextResourceContents(uri=AnyUrl(uri), mimeType="text/html", text=text), + resource=TextResourceContents(uri=uri, mime_type="text/html", text=text), ) @@ -123,7 +122,7 @@ async def test_ui_mixin_extracts_ui_resources(ui_agent): ui_resource = create_ui_resource() text_block = create_non_ui_resource() - tool_results = {"tool1": CallToolResult(content=[ui_resource, text_block], isError=False)} + tool_results = {"tool1": CallToolResult(content=[ui_resource, text_block], is_error=False)} # Create a request with tool results request = PromptMessageExtended( @@ -150,7 +149,7 @@ async def test_ui_mixin_respects_disabled_mode(mock_config, mock_context): # Create tool results with UI content ui_resource = create_ui_resource() - tool_results = {"tool1": CallToolResult(content=[ui_resource], isError=False)} + tool_results = {"tool1": CallToolResult(content=[ui_resource], is_error=False)} request = PromptMessageExtended( role="user", content=[TextContent(type="text", text="test")], tool_results=tool_results @@ -171,7 +170,7 @@ async def test_ui_mixin_auto_mode_only_acts_with_ui_content(mock_config, mock_co # Test with no UI content text_block = create_non_ui_resource() - tool_results = {"tool1": CallToolResult(content=[text_block], isError=False)} + tool_results = {"tool1": CallToolResult(content=[text_block], is_error=False)} request = PromptMessageExtended( role="user", content=[TextContent(type="text", text="test")], tool_results=tool_results @@ -192,7 +191,7 @@ async def test_ui_mixin_preserves_error_status(ui_agent): tool_results = { "tool1": CallToolResult( content=[ui_resource], - isError=True, # Error result + is_error=True, # Error result ) } @@ -203,7 +202,7 @@ async def test_ui_mixin_preserves_error_status(ui_agent): result = await ui_agent.run_tools(request) # Check that error status is preserved - assert result.tool_results["tool1"].isError is True + assert result.tool_results["tool1"].is_error is True @pytest.mark.asyncio @@ -213,7 +212,7 @@ async def test_ui_mixin_enabled_mode_processes_all_content(mock_config, mock_con # Test with only regular content text_block = create_non_ui_resource() - tool_results = {"tool1": CallToolResult(content=[text_block], isError=False)} + tool_results = {"tool1": CallToolResult(content=[text_block], is_error=False)} request = PromptMessageExtended( role="user", content=[TextContent(type="text", text="test")], tool_results=tool_results @@ -294,7 +293,7 @@ def test_is_ui_embedded_resource(ui_agent): non_ui = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("http://example.com"), mimeType="text/html", text="content" + uri="http://example.com", mime_type="text/html", text="content" ), ) assert ui_agent._is_ui_embedded_resource(non_ui) is False @@ -357,7 +356,7 @@ async def test_extract_ui_from_tool_results_handles_exceptions(ui_agent): # Create a malformed result that might cause exceptions class BrokenResult: def __init__(self): - self.isError = False + self.is_error = False @property def content(self): diff --git a/tests/unit/fast_agent/mcp/test_url_elicitation_required.py b/tests/unit/fast_agent/mcp/test_url_elicitation_required.py index 99fcd1ba2..26256a55f 100644 --- a/tests/unit/fast_agent/mcp/test_url_elicitation_required.py +++ b/tests/unit/fast_agent/mcp/test_url_elicitation_required.py @@ -1,7 +1,7 @@ """Tests for URL elicitation required error handling helpers.""" -from mcp.shared.exceptions import McpError -from mcp.types import URL_ELICITATION_REQUIRED, ErrorData +from mcp.shared.exceptions import MCPError as McpError +from mcp_types import URL_ELICITATION_REQUIRED, ErrorData from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from fast_agent.mcp.url_elicitation_required import parse_url_elicitation_required_data @@ -26,7 +26,7 @@ def test_parses_valid_data(self) -> None: assert len(parsed.elicitations) == 1 assert parsed.elicitations[0].message == "Authorization is required." assert parsed.elicitations[0].url == "https://example.com/connect" - assert parsed.elicitations[0].elicitationId == "auth-001" + assert parsed.elicitations[0].elicitation_id == "auth-001" def test_reports_missing_data(self) -> None: parsed = parse_url_elicitation_required_data(None) @@ -66,7 +66,7 @@ def test_collects_valid_and_invalid_entries(self) -> None: ) assert len(parsed.elicitations) == 1 - assert parsed.elicitations[0].elicitationId == "ok-1" + assert parsed.elicitations[0].elicitation_id == "ok-1" assert len(parsed.issues) == 2 assert "error.data.elicitations[1] is invalid" in parsed.issues[0] assert parsed.issues[1] == "error.data.elicitations[2] must be an object, got str" @@ -86,7 +86,7 @@ def test_accepts_snake_case_elicitation_id_with_non_compliant_issue(self) -> Non ) assert len(parsed.elicitations) == 1 - assert parsed.elicitations[0].elicitationId == "snake-1" + assert parsed.elicitations[0].elicitation_id == "snake-1" assert len(parsed.issues) == 1 assert "non-compliant" in parsed.issues[0] assert "elicitation_id" in parsed.issues[0] @@ -106,7 +106,7 @@ def test_trims_snake_case_elicitation_id_compatibility_value(self) -> None: ) assert len(parsed.elicitations) == 1 - assert parsed.elicitations[0].elicitationId == "snake-1" + assert parsed.elicitations[0].elicitation_id == "snake-1" assert len(parsed.issues) == 1 assert "non-compliant" in parsed.issues[0] @@ -136,7 +136,7 @@ def _make_session(self) -> MCPAgentClientSession: return session def test_detects_mcp_error_code_32042(self) -> None: - error = McpError( + error = McpError.from_error_data( ErrorData( code=URL_ELICITATION_REQUIRED, message="URL elicitation required", @@ -156,7 +156,7 @@ def test_detects_mcp_error_code_32042(self) -> None: assert self._make_session()._is_url_elicitation_required_error(error) is True def test_ignores_other_mcp_error_codes(self) -> None: - error = McpError(ErrorData(code=-32601, message="Method not found")) + error = McpError.from_error_data(ErrorData(code=-32601, message="Method not found")) assert self._make_session()._is_url_elicitation_required_error(error) is False diff --git a/tests/unit/fast_agent/plugins/test_images_plugin.py b/tests/unit/fast_agent/plugins/test_images_plugin.py index 22fa7af27..97d2ae870 100644 --- a/tests/unit/fast_agent/plugins/test_images_plugin.py +++ b/tests/unit/fast_agent/plugins/test_images_plugin.py @@ -14,7 +14,7 @@ from types import SimpleNamespace import pytest -from mcp.types import TextContent +from mcp_types import TextContent _PLUGIN_SOURCE = ( Path(__file__).resolve().parents[4] / "plugins" / "images" / "images.py" diff --git a/tests/unit/fast_agent/session/test_hydrator.py b/tests/unit/fast_agent/session/test_hydrator.py index 7d97e1b47..a6567d2c3 100644 --- a/tests/unit/fast_agent/session/test_hydrator.py +++ b/tests/unit/fast_agent/session/test_hydrator.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Literal, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.llm.request_params import RequestParams @@ -82,7 +82,7 @@ def __init__( self.config = AgentConfig(name, instruction=instruction, model="passthrough") self.config.default_request_params = RequestParams( use_history=self.config.use_history, - systemPrompt=instruction, + system_prompt=instruction, ) self.llm = SimpleNamespace( default_request_params=self.config.default_request_params.model_copy(deep=True), @@ -105,8 +105,8 @@ def set_instruction(self, instruction: str) -> None: self.config.instruction = instruction params = self.config.default_request_params assert params is not None - params.systemPrompt = instruction - self.llm.default_request_params.systemPrompt = instruction + params.system_prompt = instruction + self.llm.default_request_params.system_prompt = instruction async def set_model(self, model: str | None) -> None: self.model_updates.append(model) @@ -547,12 +547,12 @@ def _fake_rehydrate_usage(agent: _Agent, path): assert runtime_foo.instruction == "Stored foo prompt" assert runtime_foo.config.model == "anthropic.sonnet-4?reasoning=high" assert runtime_foo.model_updates == ["anthropic.sonnet-4?reasoning=high"] - assert params.maxTokens == 2048 + assert params.max_tokens == 2048 assert params.temperature == 0.3 assert params.use_history is True assert params.max_iterations == 7 - assert params.systemPrompt == "Stored foo prompt" - assert runtime_foo.llm.default_request_params.systemPrompt == "Stored foo prompt" + assert params.system_prompt == "Stored foo prompt" + assert runtime_foo.llm.default_request_params.system_prompt == "Stored foo prompt" assert runtime_foo.attached_servers == ["already-attached", "filesystem"] assert runtime_foo.usage_accumulator.turns == [_TurnRecord("restored-usage")] @@ -611,7 +611,7 @@ async def test_hydrate_session_refresh_policy_skips_prompt_and_runtime_state( runtime_foo.config.model = "openai.gpt-5-mini" refresh_params = runtime_foo.config.default_request_params assert refresh_params is not None - refresh_params.maxTokens = 512 + refresh_params.max_tokens = 512 result = await SessionHydrator().hydrate_session( session=persisted_session, @@ -626,7 +626,7 @@ async def test_hydrate_session_refresh_policy_skips_prompt_and_runtime_state( assert runtime_foo.config.model == "openai.gpt-5-mini" refreshed_params = runtime_foo.config.default_request_params assert refreshed_params is not None - assert refreshed_params.maxTokens == 512 + assert refreshed_params.max_tokens == 512 assert runtime_foo.attached_servers == ["existing-server"] assert _message_texts(runtime_foo) == ["refresh hello", "refresh done"] diff --git a/tests/unit/fast_agent/session/test_session_manager.py b/tests/unit/fast_agent/session/test_session_manager.py index 11d6691cd..dcc7cafda 100644 --- a/tests/unit/fast_agent/session/test_session_manager.py +++ b/tests/unit/fast_agent/session/test_session_manager.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Literal, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.config import get_settings, update_global_settings diff --git a/tests/unit/fast_agent/session/test_snapshot.py b/tests/unit/fast_agent/session/test_snapshot.py index a29e1b859..e2b692fd0 100644 --- a/tests/unit/fast_agent/session/test_snapshot.py +++ b/tests/unit/fast_agent/session/test_snapshot.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.agents.agent_types import AgentConfig from fast_agent.config import get_settings @@ -383,7 +383,7 @@ def test_capture_session_snapshot_maps_runtime_state_for_all_known_agents(tmp_pa model="config-foo", use_history=False, default_request_params=RequestParams( - maxTokens=111, + max_tokens=111, parallel_tool_calls=False, ), ) @@ -391,7 +391,7 @@ def test_capture_session_snapshot_maps_runtime_state_for_all_known_agents(tmp_pa "bar", instruction="template bar", model="config-bar", - default_request_params=RequestParams(maxTokens=222), + default_request_params=RequestParams(max_tokens=222), ) bar_config.source_path = tmp_path / "cards" / "bar.md" @@ -408,7 +408,7 @@ def test_capture_session_snapshot_maps_runtime_state_for_all_known_agents(tmp_pa model_name="runtime-bar", provider_name="anthropic", request_params=RequestParams( - maxTokens=4096, + max_tokens=4096, temperature=0.2, top_p=0.9, top_k=5, @@ -473,7 +473,7 @@ def test_capture_session_snapshot_maps_runtime_state_for_all_known_agents(tmp_pa foo_params = foo_config.default_request_params assert foo_params is not None assert foo_snapshot.request_settings == SessionRequestSettingsSnapshot( - max_tokens=foo_params.maxTokens, + max_tokens=foo_params.max_tokens, use_history=foo_params.use_history, parallel_tool_calls=foo_params.parallel_tool_calls, max_iterations=foo_params.max_iterations, @@ -839,7 +839,7 @@ async def test_save_history_writes_captured_snapshot_payload(tmp_path: Path) -> llm=_Llm( model_name="passthrough", provider_name="fast-agent", - request_params=RequestParams(maxTokens=123, temperature=0.4), + request_params=RequestParams(max_tokens=123, temperature=0.4), ), usage_summary={ "prompt": {"total": 11}, @@ -895,7 +895,7 @@ async def test_save_history_persists_explicit_resolved_prompts(tmp_path: Path) - llm=_Llm( model_name="passthrough", provider_name="fast-agent", - request_params=RequestParams(maxTokens=123), + request_params=RequestParams(max_tokens=123), ), message_history=[ PromptMessageExtended( @@ -936,7 +936,7 @@ async def test_save_history_tracks_most_recent_active_agent_across_known_agents( llm=_Llm( model_name="passthrough", provider_name="fast-agent", - request_params=RequestParams(maxTokens=100), + request_params=RequestParams(max_tokens=100), ), message_history=[ PromptMessageExtended( @@ -952,7 +952,7 @@ async def test_save_history_tracks_most_recent_active_agent_across_known_agents( llm=_Llm( model_name="passthrough", provider_name="fast-agent", - request_params=RequestParams(maxTokens=200), + request_params=RequestParams(max_tokens=200), ), message_history=[ PromptMessageExtended( diff --git a/tests/unit/fast_agent/session/test_trace_exporter.py b/tests/unit/fast_agent/session/test_trace_exporter.py index b8f4c5c78..98a39ee85 100644 --- a/tests/unit/fast_agent/session/test_trace_exporter.py +++ b/tests/unit/fast_agent/session/test_trace_exporter.py @@ -6,8 +6,7 @@ from typing import Any import pytest -from mcp.types import ( - AnyUrl, +from mcp_types import ( AudioContent, BlobResourceContents, CallToolRequest, @@ -255,7 +254,7 @@ def test_session_trace_exporter_writes_atif_v17_with_tool_observation( role="user", content=[ TextContent(type="text", text="check Alice's directory"), - ImageContent(type="image", data="aW1hZ2U=", mimeType="image/png"), + ImageContent(type="image", data="aW1hZ2U=", mime_type="image/png"), ], ), PromptMessageExtended( @@ -330,10 +329,10 @@ def test_session_trace_exporter_writes_atif_v17_with_tool_observation( role="user", tool_results={ error_call_id: CallToolResult( - content=[TextContent(type="text", text="command not found")], isError=True + content=[TextContent(type="text", text="command not found")], is_error=True ), call_id: CallToolResult( - content=[TextContent(type="text", text="/workspace")], isError=False + content=[TextContent(type="text", text="/workspace")], is_error=False ) }, channels={ @@ -1388,7 +1387,7 @@ def test_session_trace_exporter_writes_native_codex_tool_items(tmp_path: Path) - tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="process exit code was 0")], - isError=False, + is_error=False, ) }, ), @@ -1483,7 +1482,7 @@ def test_session_trace_exporter_applies_privacy_sanitizer_to_codex_text( tool_results={ "call_Alice": CallToolResult( content=[TextContent(type="text", text="Alice result")], - isError=False, + is_error=False, ) }, ), @@ -1708,7 +1707,7 @@ def test_session_trace_exporter_marks_tool_errors_in_codex_output(tmp_path: Path tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="process exit code was 1")], - isError=True, + is_error=True, ) }, ), @@ -1962,26 +1961,26 @@ def test_session_trace_exporter_preserves_user_attachment_content(tmp_path: Path EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file:///tmp/example.py"), - mimeType="text/x-python", + uri="file:///tmp/example.py", + mime_type="text/x-python", text="print('hello')", ), ), EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///tmp/report.pdf"), - mimeType="application/pdf", + uri="file:///tmp/report.pdf", + mime_type="application/pdf", blob="cGRm", ), ), ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/audio.mp3"), - mimeType="audio/mpeg", + uri="https://example.com/audio.mp3", + mime_type="audio/mpeg", name="audio.mp3", ), - AudioContent(type="audio", data="d2F2", mimeType="audio/wav"), + AudioContent(type="audio", data="d2F2", mime_type="audio/wav"), ], ), PromptMessageExtended( @@ -2072,20 +2071,20 @@ def test_session_trace_exporter_preserves_non_text_tool_outputs(tmp_path: Path) EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///tmp/report.pdf"), - mimeType="application/pdf", + uri="file:///tmp/report.pdf", + mime_type="application/pdf", blob="cGRm", ), ), ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/audio.mp3"), - mimeType="audio/mpeg", + uri="https://example.com/audio.mp3", + mime_type="audio/mpeg", name="audio.mp3", ), - AudioContent(type="audio", data="d2F2", mimeType="audio/wav"), + AudioContent(type="audio", data="d2F2", mime_type="audio/wav"), ], - isError=False, + is_error=False, ) }, ), @@ -2166,20 +2165,20 @@ def test_session_trace_exporter_preserves_tool_output_item_order(tmp_path: Path) EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///tmp/report-a.pdf"), - mimeType="application/pdf", + uri="file:///tmp/report-a.pdf", + mime_type="application/pdf", blob="YQ==", ), ), TextContent(type="text", text="Fetched audio"), ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/audio.mp3"), - mimeType="audio/mpeg", + uri="https://example.com/audio.mp3", + mime_type="audio/mpeg", name="audio.mp3", ), ], - isError=False, + is_error=False, ) }, ), @@ -2255,7 +2254,7 @@ def test_session_trace_exporter_preserves_user_content_alongside_tool_outputs( tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="process exit code was 0")], - isError=False, + is_error=False, ) }, ), @@ -2311,22 +2310,22 @@ def test_session_trace_exporter_preserves_assistant_attachment_content(tmp_path: role="assistant", content=[ TextContent(type="text", text="Here they are"), - ImageContent(type="image", data="aW1hZ2U=", mimeType="image/png"), + ImageContent(type="image", data="aW1hZ2U=", mime_type="image/png"), EmbeddedResource( type="resource", resource=BlobResourceContents( - uri=AnyUrl("file:///tmp/report.pdf"), - mimeType="application/pdf", + uri="file:///tmp/report.pdf", + mime_type="application/pdf", blob="cGRm", ), ), ResourceLink( type="resource_link", - uri=AnyUrl("https://example.com/audio.mp3"), - mimeType="audio/mpeg", + uri="https://example.com/audio.mp3", + mime_type="audio/mpeg", name="audio.mp3", ), - AudioContent(type="audio", data="d2F2", mimeType="audio/wav"), + AudioContent(type="audio", data="d2F2", mime_type="audio/wav"), ], stop_reason=LlmStopReason.END_TURN, ), diff --git a/tests/unit/fast_agent/skills/test_mcp_registry.py b/tests/unit/fast_agent/skills/test_mcp_registry.py index ce561a590..4e2a85afc 100644 --- a/tests/unit/fast_agent/skills/test_mcp_registry.py +++ b/tests/unit/fast_agent/skills/test_mcp_registry.py @@ -8,7 +8,7 @@ import frontmatter import pytest -from mcp.types import ( +from mcp_types import ( BlobResourceContents, ListResourcesResult, ReadResourceResult, @@ -16,7 +16,6 @@ ServerCapabilities, TextResourceContents, ) -from pydantic import AnyUrl from fast_agent.skills import mcp_registry from fast_agent.skills.mcp_registry import ( @@ -34,7 +33,7 @@ def _text(uri: str, body: str) -> ReadResourceResult: return ReadResourceResult( - contents=[TextResourceContents(uri=AnyUrl(uri), mimeType="text/plain", text=body)] + contents=[TextResourceContents(uri=uri, mime_type="text/plain", text=body)] ) @@ -42,8 +41,8 @@ def _blob(uri: str, body: bytes) -> ReadResourceResult: return ReadResourceResult( contents=[ BlobResourceContents( - uri=AnyUrl(uri), - mimeType="application/octet-stream", + uri=uri, + mime_type="application/octet-stream", blob=base64.b64encode(body).decode("ascii"), ) ] @@ -336,15 +335,15 @@ async def test_install_direct_skill_materializes_supporting_files(tmp_path) -> N }, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/SKILL.md"), name="SKILL.md"), + Resource(uri="skill://demo/SKILL.md", name="SKILL.md"), Resource( - uri=AnyUrl("skill://demo/references"), + uri="skill://demo/references", name="references", - mimeType="inode/directory", + mime_type="inode/directory", ), ], "skill://demo/references": [ - Resource(uri=AnyUrl("skill://demo/references/GUIDE.md"), name="GUIDE.md"), + Resource(uri="skill://demo/references/GUIDE.md", name="GUIDE.md"), ], }, ) @@ -380,7 +379,7 @@ async def test_install_direct_skill_walks_lowercase_skill_md_url(tmp_path) -> No }, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/GUIDE.md"), name="GUIDE.md"), + Resource(uri="skill://demo/GUIDE.md", name="GUIDE.md"), ], }, ) @@ -414,7 +413,7 @@ async def test_install_direct_skill_rejects_oversized_supporting_file( }, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/BIG.md"), name="BIG.md"), + Resource(uri="skill://demo/BIG.md", name="BIG.md"), ], }, ) @@ -465,8 +464,8 @@ async def test_install_direct_skill_does_not_overwrite_verified_skill_md(tmp_pat }, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/SKILL.md"), name="SKILL.md"), - Resource(uri=AnyUrl("skill://demo/skill.md"), name="skill.md"), + Resource(uri="skill://demo/SKILL.md", name="SKILL.md"), + Resource(uri="skill://demo/skill.md", name="skill.md"), ], }, ) @@ -498,8 +497,8 @@ async def test_install_direct_skill_rolls_back_partial_supporting_files(tmp_path # GUIDE.md is staged first, then the unsafe backslash sibling raises # mid-walk (validated by _validate_archive_name). "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/GUIDE.md"), name="GUIDE.md"), - Resource(uri=AnyUrl(r"skill://demo/..\..\evil.md"), name="evil.md"), + Resource(uri="skill://demo/GUIDE.md", name="GUIDE.md"), + Resource(uri=r"skill://demo/..\..\evil.md", name="evil.md"), ], }, ) @@ -535,7 +534,7 @@ def __init__(self) -> None: async def read_directory(self, uri, *, server_name=None, cursor=None): del uri, server_name, cursor self.calls += 1 - return ListResourcesResult(resources=[], nextCursor="more") + return ListResourcesResult(resources=[], next_cursor="more") aggregator = _InfinitePagerAggregator() @@ -562,7 +561,7 @@ async def test_install_direct_skill_skips_walk_without_capability(tmp_path) -> N responses={"skill://demo/SKILL.md": skill_text}, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/extra.md"), name="extra.md"), + Resource(uri="skill://demo/extra.md", name="extra.md"), ] }, ) @@ -746,15 +745,15 @@ async def test_install_warns_on_unverified_supporting_files(tmp_path, monkeypatc }, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/SKILL.md"), name="SKILL.md"), + Resource(uri="skill://demo/SKILL.md", name="SKILL.md"), Resource( - uri=AnyUrl("skill://demo/references"), + uri="skill://demo/references", name="references", - mimeType="inode/directory", + mime_type="inode/directory", ), ], "skill://demo/references": [ - Resource(uri=AnyUrl("skill://demo/references/GUIDE.md"), name="GUIDE.md"), + Resource(uri="skill://demo/references/GUIDE.md", name="GUIDE.md"), ], }, ) @@ -944,7 +943,7 @@ async def test_supporting_files_count_against_server_budget(tmp_path, monkeypatc }, directories={ "skill://demo": [ - Resource(uri=AnyUrl("skill://demo/GUIDE.md"), name="GUIDE.md"), + Resource(uri="skill://demo/GUIDE.md", name="GUIDE.md"), ], }, ) diff --git a/tests/unit/fast_agent/test_a2a_content.py b/tests/unit/fast_agent/test_a2a_content.py index a7b7c2a30..1692cf2d7 100644 --- a/tests/unit/fast_agent/test_a2a_content.py +++ b/tests/unit/fast_agent/test_a2a_content.py @@ -1,7 +1,6 @@ import base64 -from mcp.types import AudioContent, EmbeddedResource, TextResourceContents -from pydantic import AnyUrl +from mcp_types import AudioContent, EmbeddedResource, TextResourceContents from fast_agent.a2a.content import part_from_content @@ -11,7 +10,7 @@ def test_part_from_content_converts_audio() -> None: AudioContent( type="audio", data=base64.b64encode(b"audio bytes").decode(), - mimeType="audio/wav", + mime_type="audio/wav", ) ) @@ -25,8 +24,8 @@ def test_part_from_content_preserves_text_resource_metadata() -> None: EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource:///reports/final%20summary.txt"), - mimeType="text/markdown", + uri="resource:///reports/final%20summary.txt", + mime_type="text/markdown", text="# Summary", ), ) diff --git a/tests/unit/fast_agent/test_a2a_remote_agent_events.py b/tests/unit/fast_agent/test_a2a_remote_agent_events.py index bc9b4f951..259097ca8 100644 --- a/tests/unit/fast_agent/test_a2a_remote_agent_events.py +++ b/tests/unit/fast_agent/test_a2a_remote_agent_events.py @@ -16,8 +16,7 @@ TaskStatus, ) from google.protobuf.json_format import MessageToDict -from mcp.types import EmbeddedResource, TextContent, TextResourceContents -from pydantic import AnyUrl +from mcp_types import EmbeddedResource, TextContent, TextResourceContents from fast_agent.a2a.config import A2AAgentConfig from fast_agent.a2a.content import filename_from_uri @@ -340,8 +339,8 @@ def test_a2a_remote_agent_sends_json_text_resources_as_data_parts() -> None: EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("resource:///query.json"), - mimeType="application/json", + uri="resource:///query.json", + mime_type="application/json", text='{"format": "markdown", "limit": 5}', ), ) diff --git a/tests/unit/fast_agent/test_cli_uri_sources.py b/tests/unit/fast_agent/test_cli_uri_sources.py index 59cafb878..c7022dc5a 100644 --- a/tests/unit/fast_agent/test_cli_uri_sources.py +++ b/tests/unit/fast_agent/test_cli_uri_sources.py @@ -1,5 +1,5 @@ from click.utils import strip_ansi -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.cli.command_support import get_settings_or_exit from fast_agent.llm.structured_schema import load_json_schema_file diff --git a/tests/unit/fast_agent/test_workflow_telemetry.py b/tests/unit/fast_agent/test_workflow_telemetry.py index 80c00c78b..c4401b8ba 100644 --- a/tests/unit/fast_agent/test_workflow_telemetry.py +++ b/tests/unit/fast_agent/test_workflow_telemetry.py @@ -3,12 +3,12 @@ from typing import TYPE_CHECKING, Any import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.workflow_telemetry import ToolHandlerWorkflowTelemetry if TYPE_CHECKING: - from mcp.types import ContentBlock + from mcp_types import ContentBlock class _RecordingToolHandler: diff --git a/tests/unit/fast_agent/tools/test_apply_patch_tool.py b/tests/unit/fast_agent/tools/test_apply_patch_tool.py index f545b548d..98a9acf05 100644 --- a/tests/unit/fast_agent/tools/test_apply_patch_tool.py +++ b/tests/unit/fast_agent/tools/test_apply_patch_tool.py @@ -1,4 +1,4 @@ -from mcp.types import Tool +from mcp_types import Tool from fast_agent.tools.apply_patch_tool import ( OPENAI_RESPONSES_CUSTOM_TOOL_META_KEY, diff --git a/tests/unit/fast_agent/tools/test_composite_filesystem_runtime.py b/tests/unit/fast_agent/tools/test_composite_filesystem_runtime.py index d3ee6a1f1..c3df25424 100644 --- a/tests/unit/fast_agent/tools/test_composite_filesystem_runtime.py +++ b/tests/unit/fast_agent/tools/test_composite_filesystem_runtime.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from mcp.types import CallToolResult, TextContent, Tool +from mcp_types import CallToolResult, TextContent, Tool from fast_agent.mcp.tool_result_metadata import fatal_tool_error from fast_agent.tools.composite_filesystem_runtime import CompositeFilesystemRuntime @@ -12,7 +12,7 @@ class _Runtime: def __init__(self, *tool_names: str, runtime_type: str = "test") -> None: - self.tools = [Tool(name=name, inputSchema={}) for name in tool_names] + self.tools = [Tool(name=name, input_schema={}) for name in tool_names] self.calls: list[tuple[str, dict[str, Any] | None, str | None, RequestParams | None]] = [] self.runtime_type = runtime_type @@ -25,7 +25,7 @@ async def call_tool( request_params: RequestParams | None = None, ) -> CallToolResult: self.calls.append((name, arguments, tool_use_id, request_params)) - return CallToolResult(content=[TextContent(type="text", text=name)], isError=False) + return CallToolResult(content=[TextContent(type="text", text=name)], is_error=False) def metadata(self) -> dict[str, Any]: return {"type": self.runtime_type, "tools": [tool.name for tool in self.tools]} @@ -45,7 +45,7 @@ async def test_composite_runtime_routes_to_first_runtime_with_tool() -> None: request_params=request_params, ) - assert result.isError is False + assert result.is_error is False assert primary.calls == [ ("read_text_file", {"path": "sample.txt"}, "tool-use-1", request_params) ] @@ -60,7 +60,7 @@ async def test_composite_runtime_routes_to_fallback_for_unique_tool() -> None: result = await runtime.call_tool("apply_patch", {"input": "*** Begin Patch"}) - assert result.isError is False + assert result.is_error is False assert primary.calls == [] assert fallback.calls == [("apply_patch", {"input": "*** Begin Patch"}, None, None)] @@ -74,7 +74,7 @@ async def test_composite_runtime_reports_unsupported_tool() -> None: result = await runtime.call_tool("missing") - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: unsupported filesystem tool 'missing'" @@ -91,7 +91,7 @@ async def test_composite_runtime_does_not_fallback_for_acp_owned_read_write() -> result = await runtime.call_tool("write_text_file", {"path": "x", "content": "y"}) - assert result.isError is True + assert result.is_error is True assert fatal_tool_error(result) == ("Error: unsupported filesystem tool 'write_text_file'") assert primary.calls == [] assert fallback.calls == [] diff --git a/tests/unit/fast_agent/tools/test_elicitation.py b/tests/unit/fast_agent/tools/test_elicitation.py index 3d3beb6f4..941f2bb2b 100644 --- a/tests/unit/fast_agent/tools/test_elicitation.py +++ b/tests/unit/fast_agent/tools/test_elicitation.py @@ -420,9 +420,9 @@ def test_get_elicitation_tool_builds_sanitized_schema_without_refs() -> None: tool = get_elicitation_tool() assert tool.name == HUMAN_INPUT_TOOL_NAME - refs = _collect_schema_refs(tool.inputSchema) + refs = _collect_schema_refs(tool.input_schema) assert refs == [] - properties = tool.inputSchema.get("properties") + properties = tool.input_schema.get("properties") assert isinstance(properties, dict) assert "fields" in properties diff --git a/tests/unit/fast_agent/tools/test_environment_filesystem_runtime.py b/tests/unit/fast_agent/tools/test_environment_filesystem_runtime.py index 91ca7e8f1..7fbb04ce8 100644 --- a/tests/unit/fast_agent/tools/test_environment_filesystem_runtime.py +++ b/tests/unit/fast_agent/tools/test_environment_filesystem_runtime.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest -from mcp.types import ImageContent, TextContent +from mcp_types import ImageContent, TextContent from PIL import Image from fast_agent.agents.agent_types import AgentConfig @@ -195,8 +195,8 @@ async def test_environment_filesystem_runtime_reads_and_writes_remote_files() -> ) read = await runtime.call_tool("read_text_file", {"path": "notes.txt", "line": 2}) - assert write.isError is False - assert read.isError is False + assert write.is_error is False + assert read.is_error is False assert env.files["/workspace/notes.txt"] == "hello\nworld\n" assert _text(read) == "world" @@ -209,7 +209,7 @@ async def test_environment_filesystem_runtime_preserves_full_file_content() -> N read = await runtime.call_tool("read_text_file", {"path": "notes.txt"}) - assert read.isError is False + assert read.is_error is False assert _text(read) == "hello\r\nworld\r\n" @@ -227,7 +227,7 @@ async def test_environment_filesystem_runtime_attaches_environment_media() -> No pending = runtime.consume_pending_media_attachments() assert "attach_media" in tool_names - assert result.isError is False + assert result.is_error is False assert "Staged image.png as embedded image/png media input" in _text(result) assert len(pending) == 1 assert isinstance(pending[0], ImageContent) @@ -263,7 +263,7 @@ async def test_filesystem_runtimes_normalize_png_with_excess_raster_data( {"source": "crop.png", "mime_type": "image/png"}, ) - assert result.isError is False + assert result.is_error is False pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ImageContent) @@ -302,7 +302,7 @@ async def test_environment_filesystem_runtime_rejects_invalid_image_data( {"source": "image.png", "mime_type": "image/png"}, ) - assert result.isError is True + assert result.is_error is True assert error in _text(result) assert runtime.consume_pending_media_attachments() == [] @@ -319,11 +319,11 @@ async def test_environment_filesystem_runtime_converts_ppm_to_png() -> None: ) pending = runtime.consume_pending_media_attachments() - assert result.isError is False + assert result.is_error is False assert "Converted screen.ppm from image/x-portable-anymap to image/png" in _text(result) assert len(pending) == 1 assert isinstance(pending[0], ImageContent) - assert pending[0].mimeType == "image/png" + assert pending[0].mime_type == "image/png" assert base64.b64decode(pending[0].data).startswith(b"\x89PNG\r\n\x1a\n") @@ -336,11 +336,11 @@ async def test_environment_filesystem_runtime_detects_pillow_image_without_known result = await runtime.call_tool("attach_media", {"source": "screen.tga"}) pending = runtime.consume_pending_media_attachments() - assert result.isError is False + assert result.is_error is False assert "Converted screen.tga from image/x-tga to image/png" in _text(result) assert len(pending) == 1 assert isinstance(pending[0], ImageContent) - assert pending[0].mimeType == "image/png" + assert pending[0].mime_type == "image/png" assert base64.b64decode(pending[0].data).startswith(b"\x89PNG\r\n\x1a\n") @@ -379,7 +379,7 @@ async def remove(self, path: str) -> None: assert "attach_media" not in {tool.name for tool in runtime.tools} result = await runtime.call_tool("attach_media", {"source": "image.png"}) - assert result.isError is True + assert result.is_error is True @pytest.mark.asyncio @@ -419,7 +419,7 @@ async def test_environment_filesystem_runtime_applies_patch_to_remote_files() -> }, ) - assert result.isError is False + assert result.is_error is False assert env.files["/workspace/notes.txt"] == "ONE\ntwo\n" assert "M notes.txt" in _text(result) @@ -445,7 +445,7 @@ async def test_environment_filesystem_runtime_move_removes_source_file() -> None }, ) - assert result.isError is False + assert result.is_error is False assert env.files["/workspace/b.py"] == "print('hello')\n" assert "/workspace/a.py" not in env.files @@ -466,7 +466,7 @@ async def write_text(self, path: str, content: str) -> None: {"path": "notes.txt", "old_string": "world", "new_string": "there"}, ) - assert result.isError is True + assert result.is_error is True assert _text(result) == "Error writing file: disk full" @@ -492,9 +492,9 @@ async def test_mcp_agent_routes_file_tools_to_injected_execution_environment() - read = await agent.call_tool("read_text_file", {"path": "remote.txt"}) shell = await agent.call_tool("Bash", {"command": "pwd"}) - assert read.isError is False + assert read.is_error is False assert _text(read) == "remote file\n" - assert shell.isError is False + assert shell.is_error is False assert env.requests[-1].command == "pwd" await agent._aggregator.close() @@ -547,7 +547,7 @@ async def test_mcp_agent_stages_media_from_injected_execution_environment() -> N pending = agent._consume_pending_media_attachments() assert "attach_media" in tool_names - assert result.isError is False + assert result.is_error is False assert len(pending) == 1 assert isinstance(pending[0], ImageContent) @@ -575,7 +575,7 @@ async def test_mcp_agent_uses_local_environment_filesystem_cwd( assert "read_text_file" in tool_names assert "attach_media" in tool_names - assert result.isError is False + assert result.is_error is False assert _text(result) == "from local environment\n" await agent._aggregator.close() @@ -609,7 +609,7 @@ async def test_mcp_agent_reads_environment_skills_with_environment_read_tool() - assert agent.skill_read_tool_name == "read_text_file" read = await agent.call_tool("read_text_file", {"path": str(manifest.path)}) - assert read.isError is False + assert read.is_error is False assert "Use alpha." in _text(read) await agent._aggregator.close() diff --git a/tests/unit/fast_agent/tools/test_huggingface_sandbox_environment.py b/tests/unit/fast_agent/tools/test_huggingface_sandbox_environment.py index 3a5d8bfd9..d1b5d94a7 100644 --- a/tests/unit/fast_agent/tools/test_huggingface_sandbox_environment.py +++ b/tests/unit/fast_agent/tools/test_huggingface_sandbox_environment.py @@ -641,8 +641,8 @@ async def test_shell_runtime_terminate_process_kills_huggingface_remote_process( ) terminated = await runtime.terminate_process({"process_id": "process-1"}) - assert started.isError is False - assert terminated.isError is False + assert started.is_error is False + assert terminated.is_error is False assert sandbox.process.kill_count == 1 assert sandbox.process.running is False await runtime.close() diff --git a/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py b/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py index 58c8f464f..863b8a3d8 100644 --- a/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py +++ b/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from mcp.types import ( +from mcp_types import ( BlobResourceContents, EmbeddedResource, ImageContent, @@ -50,7 +50,7 @@ def test_read_text_file_tool_schema_matches_acp_signature() -> None: tool = _tool_by_name(runtime, "read_text_file") assert tool is not None assert tool.name == "read_text_file" - assert tool.inputSchema == { + assert tool.input_schema == { "type": "object", "properties": { "path": { @@ -79,7 +79,7 @@ def test_write_text_file_tool_schema_matches_acp_signature() -> None: tool = _tool_by_name(runtime, "write_text_file") assert tool is not None assert tool.name == "write_text_file" - assert tool.inputSchema == { + assert tool.input_schema == { "type": "object", "properties": { "path": { @@ -205,7 +205,7 @@ async def test_attach_media_local_png_stages_image_content(tmp_path: Path) -> No result = await runtime.attach_media({"source": "pixel.png"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert len(result.content) == 1 @@ -216,7 +216,7 @@ async def test_attach_media_local_png_stages_image_content(tmp_path: Path) -> No pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ImageContent) - assert pending[0].mimeType == "image/png" + assert pending[0].mime_type == "image/png" assert runtime.consume_pending_media_attachments() == [] @@ -232,7 +232,7 @@ async def test_attach_media_local_pdf_stages_embedded_blob(tmp_path: Path) -> No result = await runtime.attach_media({"source": pdf_path.as_uri()}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert len(result.content) == 1 assert get_tool_result_media_preview(result) is None @@ -240,7 +240,7 @@ async def test_attach_media_local_pdf_stages_embedded_blob(tmp_path: Path) -> No assert len(pending) == 1 assert isinstance(pending[0], EmbeddedResource) assert isinstance(pending[0].resource, BlobResourceContents) - assert pending[0].resource.mimeType == "application/pdf" + assert pending[0].resource.mime_type == "application/pdf" @pytest.mark.asyncio @@ -253,14 +253,14 @@ async def test_attach_media_https_image_stages_resource_link() -> None: result = await runtime.attach_media({"source": "https://example.com/photo.jpg"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert len(result.content) == 1 pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ResourceLink) assert str(pending[0].uri) == "https://example.com/photo.jpg" - assert pending[0].mimeType == "image/jpeg" + assert pending[0].mime_type == "image/jpeg" @pytest.mark.asyncio @@ -278,11 +278,11 @@ async def test_attach_media_trims_optional_mime_type() -> None: } ) - assert result.isError is False + assert result.is_error is False pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ResourceLink) - assert pending[0].mimeType == "image/png" + assert pending[0].mime_type == "image/png" @pytest.mark.asyncio @@ -302,7 +302,7 @@ async def test_attach_media_ignores_blank_optional_name(tmp_path: Path) -> None: } ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert "Staged pixel.png as embedded image/png media input" in result.content[0].text @@ -318,13 +318,13 @@ async def test_attach_media_youtube_url_stages_video_resource_link() -> None: result = await runtime.attach_media({"source": "https://WWW.YouTube.com/watch?v=dQw4w9WgXcQ"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert len(result.content) == 1 pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ResourceLink) - assert pending[0].mimeType == "video/mp4" + assert pending[0].mime_type == "video/mp4" @pytest.mark.asyncio @@ -345,7 +345,7 @@ async def test_attach_media_rejects_google_remote_pdf_link() -> None: result = await runtime.attach_media({"source": "https://example.com/report.pdf"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "remote PDF links" in result.content[0].text @@ -363,7 +363,7 @@ async def test_attach_media_rejects_unsupported_mime_for_model(tmp_path: Path) - result = await runtime.attach_media({"source": str(image_path)}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert ( @@ -398,7 +398,7 @@ async def test_attach_media_converts_unsupported_local_image( } ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert ( @@ -408,7 +408,7 @@ async def test_attach_media_converts_unsupported_local_image( pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ImageContent) - assert pending[0].mimeType == target_mime + assert pending[0].mime_type == target_mime assert base64.b64decode(pending[0].data).startswith(magic) @@ -425,7 +425,7 @@ async def test_attach_media_rejects_oversized_local_file(tmp_path: Path) -> None result = await runtime.attach_media({"source": str(pdf_path)}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == ( @@ -464,7 +464,7 @@ async def test_attach_media_rejects_internal_resource_uri() -> None: result = await runtime.attach_media({"source": "internal://fast-agent/example.pdf"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "use get_resource" in result.content[0].text @@ -494,7 +494,7 @@ async def test_attach_media_rejects_invalid_arguments( result = await runtime.attach_media(arguments) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == expected_error @@ -521,7 +521,7 @@ async def test_read_text_file_reads_full_file(tmp_path: Path) -> None: result = await runtime.read_text_file({"path": str(test_file)}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "first\nsecond\nthird\n" @@ -537,7 +537,7 @@ async def test_read_text_file_supports_line_and_limit(tmp_path: Path) -> None: {"path": str(test_file), "line": 2, "limit": 2}, ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "second\nthird" @@ -555,7 +555,7 @@ async def test_read_text_file_rejects_boolean_line_or_limit( result = await runtime.read_text_file({"path": str(test_file), field: True}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert ( @@ -570,7 +570,7 @@ async def test_read_text_file_rejects_whitespace_only_path() -> None: result = await runtime.read_text_file({"path": " "}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error: 'path' argument is required and must be a string" @@ -594,7 +594,7 @@ async def test_read_text_file_resolves_relative_paths_from_working_directory( result = await runtime.read_text_file({"path": "nested/sample.txt"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "relative content" @@ -614,7 +614,7 @@ def deny_read(self: Path, *args, **kwargs) -> str: result = await runtime.read_text_file({"path": "secret.txt"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Permission denied for file: secret.txt." @@ -629,7 +629,7 @@ async def test_write_text_file_writes_file_successfully(tmp_path: Path) -> None: {"path": str(output_file), "content": "hello world"}, ) - assert result.isError is False + assert result.is_error is False assert output_file.read_text(encoding="utf-8") == "hello world" assert result.content is not None assert isinstance(result.content[0], TextContent) @@ -645,7 +645,7 @@ async def test_write_text_file_creates_parent_directories(tmp_path: Path) -> Non {"path": str(output_file), "content": "nested"}, ) - assert result.isError is False + assert result.is_error is False assert output_file.exists() assert output_file.read_text(encoding="utf-8") == "nested" @@ -660,7 +660,7 @@ async def test_write_text_file_overwrites_existing_content(tmp_path: Path) -> No {"path": str(output_file), "content": "new"}, ) - assert result.isError is False + assert result.is_error is False assert output_file.read_text(encoding="utf-8") == "new" @@ -677,7 +677,7 @@ async def test_write_text_file_invalid_args_returns_error() -> None: ] for result in invalid_results: - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text.startswith("Error:") @@ -700,7 +700,7 @@ async def test_write_text_file_resolves_relative_paths_from_working_directory( ) output_file = project_dir / "nested" / "output.txt" - assert result.isError is False + assert result.is_error is False assert output_file.exists() assert output_file.read_text(encoding="utf-8") == "relative write" @@ -719,7 +719,7 @@ def deny_write(self: Path, *args, **kwargs) -> int: result = await runtime.write_text_file({"path": "secret.txt", "content": "x"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Permission denied for file: secret.txt." @@ -734,7 +734,7 @@ def test_apply_patch_tool_schema_uses_input_field() -> None: tool = _tool_by_name(runtime, APPLY_PATCH_TOOL_NAME) assert tool is not None assert tool.name == APPLY_PATCH_TOOL_NAME - assert tool.inputSchema == { + assert tool.input_schema == { "type": "object", "properties": { "input": { @@ -774,7 +774,7 @@ async def test_apply_patch_updates_file_relative_to_working_directory(tmp_path: ) result = await runtime.apply_patch({"input": patch_text}) - assert result.isError is False + assert result.is_error is False assert file_path.read_text(encoding="utf-8") == "ONE\ntwo\n" assert result.content is not None assert isinstance(result.content[0], TextContent) @@ -792,7 +792,7 @@ async def test_apply_patch_invalid_args_returns_error() -> None: ] for result in invalid_results: - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text.startswith("Error:") diff --git a/tests/unit/fast_agent/tools/test_local_shell_output_spool.py b/tests/unit/fast_agent/tools/test_local_shell_output_spool.py index e86181a52..fa58c3cf8 100644 --- a/tests/unit/fast_agent/tools/test_local_shell_output_spool.py +++ b/tests/unit/fast_agent/tools/test_local_shell_output_spool.py @@ -11,7 +11,7 @@ from shutil import rmtree import pytest -from mcp.types import TextContent +from mcp_types import TextContent import fast_agent.tools.shell_runtime as shell_runtime_module from fast_agent.config import Settings, ShellSettings diff --git a/tests/unit/fast_agent/tools/test_runtime_tool_sources.py b/tests/unit/fast_agent/tools/test_runtime_tool_sources.py index e19e33335..8e13821ea 100644 --- a/tests/unit/fast_agent/tools/test_runtime_tool_sources.py +++ b/tests/unit/fast_agent/tools/test_runtime_tool_sources.py @@ -3,7 +3,7 @@ import logging import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.acp.filesystem_runtime import ACPFilesystemRuntime from fast_agent.acp.terminal_runtime import ACPTerminalRuntime @@ -167,7 +167,7 @@ async def get_tool_call_id_for_tool_use(self, tool_use_id: str) -> str | None: tool_use_id="tool-use-1", ) - assert result.isError is True + assert result.is_error is True assert connection.read_calls == 0 assert connection.write_calls == 0 assert connection.update_calls == 1 @@ -203,7 +203,7 @@ async def read_text_file(self, **_kwargs): result = await runtime.read_text_file({"path": "example.txt", field: True}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "must be an integer greater than or equal to 1" in result.content[0].text @@ -232,7 +232,7 @@ async def read_text_file(self, **kwargs): result = await runtime.read_text_file({"path": "example.txt", "line": 2, "limit": 3}) - assert result.isError is False + assert result.is_error is False assert connection.read_kwargs == { "path": "example.txt", "session_id": "session", diff --git a/tests/unit/fast_agent/tools/test_shell_runtime.py b/tests/unit/fast_agent/tools/test_shell_runtime.py index daea378e7..8a094968c 100644 --- a/tests/unit/fast_agent/tools/test_shell_runtime.py +++ b/tests/unit/fast_agent/tools/test_shell_runtime.py @@ -12,7 +12,7 @@ from typing import Any, cast import pytest -from mcp.types import TextContent +from mcp_types import TextContent import fast_agent.tools.local_shell_executor as local_shell_executor import fast_agent.tools.shell_runtime as shell_runtime_module @@ -23,6 +23,7 @@ MAX_TERMINAL_OUTPUT_BYTE_LIMIT, ) from fast_agent.event_progress import ProgressAction +from fast_agent.mcp.tool_result_metadata import tool_result_display_metadata from fast_agent.tools.execution_environment import ( ShellExecution, ShellExecutionCallbacks, @@ -546,7 +547,7 @@ def test_execute_tool_schema_declares_per_call_options() -> None: assert "keeps running and returns a process ID" in runtime.tool.description assert "Do not append '&'" in runtime.tool.description assert "lifecycle='persistent'" in runtime.tool.description - assert set(runtime.tool.inputSchema["properties"]) == { + assert set(runtime.tool.input_schema["properties"]) == { "command", "cwd", "background", @@ -554,24 +555,24 @@ def test_execute_tool_schema_declares_per_call_options() -> None: "yield_after_idle_sec", "output_byte_limit", } - lifecycle_schema = runtime.tool.inputSchema["properties"]["lifecycle"] + lifecycle_schema = runtime.tool.input_schema["properties"]["lifecycle"] assert lifecycle_schema["enum"] == ["session", "persistent"] assert lifecycle_schema["default"] == "persistent" - assert runtime.tool.inputSchema["required"] == ["command"] - assert runtime.tool.inputSchema["additionalProperties"] is False + assert runtime.tool.input_schema["required"] == ["command"] + assert runtime.tool.input_schema["additionalProperties"] is False assert {tool.name for tool in runtime.tools} == { "execute", "poll_process", "terminate_process", } poll_tool = next(tool for tool in runtime.tools if tool.name == "poll_process") - assert set(poll_tool.inputSchema["properties"]) == { + assert set(poll_tool.input_schema["properties"]) == { "process_id", "wait_sec", "wake_on_output", } - assert poll_tool.inputSchema["properties"]["wait_sec"]["maximum"] == 250 - wake_schema = poll_tool.inputSchema["properties"]["wake_on_output"] + assert poll_tool.input_schema["properties"]["wait_sec"]["maximum"] == 250 + wake_schema = poll_tool.input_schema["properties"]["wake_on_output"] assert wake_schema["default"] is False assert "quiet for 2 seconds" in wake_schema["description"] assert "does not end the wait by default" in (poll_tool.description or "") @@ -587,22 +588,22 @@ def test_minimal_process_profile_exposes_only_bash_and_process() -> None: assert [tool.name for tool in runtime.tools] == ["Bash", "Process"] assert runtime.tool is not None - assert set(runtime.tool.inputSchema["properties"]) == { + assert set(runtime.tool.input_schema["properties"]) == { "command", "run_in_background", } process_tool = runtime.tools[1] - assert set(process_tool.inputSchema["properties"]) == { + assert set(process_tool.input_schema["properties"]) == { "process_id", "action", "wait_sec", } - assert process_tool.inputSchema["properties"]["action"]["enum"] == [ + assert process_tool.input_schema["properties"]["action"]["enum"] == [ "status", "wait", "stop", ] - wait_schema = process_tool.inputSchema["properties"]["wait_sec"] + wait_schema = process_tool.input_schema["properties"]["wait_sec"] assert "default" not in wait_schema assert wait_schema["maximum"] == 250 assert "Values below 10 are clamped to 10" in wait_schema["description"] @@ -645,7 +646,7 @@ async def test_minimal_bash_rejects_detachment_before_environment_execution( {"command": command}, ) - assert result.isError is True + assert result.is_error is True assert environment.requests == [] assert isinstance(result.content[0], TextContent) assert "run_in_background=true" in result.content[0].text @@ -666,7 +667,7 @@ async def test_minimal_bash_accepts_bitwise_arithmetic() -> None: {"command": "echo $((3 & 1))"}, ) - assert result.isError is False + assert result.is_error is False assert [request.command for request in environment.requests] == ["echo $((3 & 1))"] @@ -888,7 +889,7 @@ def test_poll_process_schema_uses_configured_maximum_wait() -> None: ) poll_tool = next(tool for tool in runtime.tools if tool.name == "poll_process") - wait_schema = poll_tool.inputSchema["properties"]["wait_sec"] + wait_schema = poll_tool.input_schema["properties"]["wait_sec"] assert wait_schema["maximum"] == 240 assert "through 240" in wait_schema["description"] assert "Routine stdout/stderr is buffered" in (poll_tool.description or "") @@ -903,7 +904,7 @@ def test_poll_process_uses_model_default_wait_and_buffers_output() -> None: ) poll_tool = next(tool for tool in runtime.tools if tool.name == "poll_process") - wait_schema = poll_tool.inputSchema["properties"]["wait_sec"] + wait_schema = poll_tool.input_schema["properties"]["wait_sec"] assert wait_schema["default"] == 30 assert _parse_poll(runtime, {"process_id": "process-1"}).wait_sec == 30 assert _parse_poll(runtime, {"process_id": "process-1"}).wake_on_output is False @@ -927,7 +928,7 @@ def test_poll_process_clamps_model_default_to_configured_maximum() -> None: ) poll_tool = next(tool for tool in runtime.tools if tool.name == "poll_process") - wait_schema = poll_tool.inputSchema["properties"]["wait_sec"] + wait_schema = poll_tool.input_schema["properties"]["wait_sec"] assert wait_schema["default"] == 50 assert _parse_poll(runtime, {"process_id": "process-1"}).wait_sec == 50 @@ -942,7 +943,7 @@ def test_poll_process_updates_default_for_model_switch() -> None: runtime.set_process_poll_default_wait_seconds(25) poll_tool = next(tool for tool in runtime.tools if tool.name == "poll_process") - wait_schema = poll_tool.inputSchema["properties"]["wait_sec"] + wait_schema = poll_tool.input_schema["properties"]["wait_sec"] assert wait_schema["default"] == 25 assert _parse_poll(runtime, {"process_id": "process-1"}).wait_sec == 25 @@ -962,7 +963,7 @@ async def test_poll_process_rejects_wait_above_configured_maximum() -> None: {"process_id": "process-1", "wait_sec": 241} ) - assert result.isError is True + assert result.is_error is True assert isinstance(result.content[0], TextContent) assert "'wait_sec' argument must be at most 240" in result.content[0].text @@ -1088,7 +1089,7 @@ async def test_terminate_process_returns_when_term_exits_process() -> None: result = await runtime.terminate_process({"process_id": "process-1"}) elapsed = time.monotonic() - started - assert result.isError is False + assert result.is_error is False assert elapsed < 1.5 finally: await runtime.close() @@ -1120,7 +1121,7 @@ async def test_execute_simple_command() -> None: # Use 'echo' which works on Windows, Linux, macOS result = await runtime.execute({"command": "echo hello"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) @@ -1142,7 +1143,7 @@ async def test_execute_command_with_exit_code() -> None: # Unix shells result = await runtime.execute({"command": "false"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) @@ -1229,7 +1230,7 @@ async def test_execute_tool_uses_runtime_working_directory_with_shared_environme result = await runtime.execute({"command": "pwd"}) - assert result.isError is False + assert result.is_error is False assert environment.cwd == "/workspace" assert [request.cwd for request in environment.requests] == ["/agent-cwd"] assert [request.timeout for request in environment.requests] == [None] @@ -1255,7 +1256,7 @@ async def test_execute_honors_per_call_cwd_and_yield_options() -> None: } ) - assert result.isError is False + assert result.is_error is False assert [ (request.cwd, request.timeout, request.terminate_after_idle) for request in environment.requests @@ -1281,7 +1282,7 @@ async def test_execute_resolves_relative_per_call_cwd_against_active_working_dir } ) - assert result.isError is False + assert result.is_error is False assert environment.resolved_paths[-1] == "/agent-cwd/subdir" assert environment.requests[0].cwd == "/agent-cwd/subdir" @@ -1298,8 +1299,8 @@ async def test_execute_rejects_unknown_arguments_without_running() -> None: timeout_result = await runtime.execute({"command": "touch /tmp/nope", "timeout": 120000}) unknown_result = await runtime.execute({"command": "touch /tmp/nope", "stream": True}) - assert timeout_result.isError is True - assert unknown_result.isError is True + assert timeout_result.is_error is True + assert unknown_result.is_error is True assert environment.requests == [] assert timeout_result.content is not None assert isinstance(timeout_result.content[0], TextContent) @@ -1319,7 +1320,7 @@ async def test_execute_rejects_idle_yield_over_thirty_seconds() -> None: {"command": "sleep 3600", "yield_after_idle_sec": 31} ) - assert result.isError is True + assert result.is_error is True assert environment.requests == [] assert result.content is not None assert isinstance(result.content[0], TextContent) @@ -1339,7 +1340,7 @@ async def test_silent_command_yields_alive_then_poll_reports_completion() -> Non result = await runtime.execute({"command": "slow-build"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert "Process is still running" in result.content[0].text @@ -1368,12 +1369,12 @@ async def test_silent_command_yields_alive_then_poll_reports_completion() -> Non {"process_id": "process-1", "wait_sec": 1} ) - assert poll_result.isError is False + assert poll_result.is_error is False assert poll_result.content is not None assert isinstance(poll_result.content[0], TextContent) assert "managed complete" in poll_result.content[0].text assert "process exit code was 0" in poll_result.content[0].text - assert getattr(poll_result, "output_line_count", None) == 1 + assert tool_result_display_metadata(poll_result).get("output_line_count") == 1 @pytest.mark.asyncio @@ -1445,7 +1446,7 @@ async def sample(working_directory: str, pid: int | None): result = await runtime.poll_process({"process_id": "process-1"}) - assert result.isError is False + assert result.is_error is False assert time.monotonic() - started < 0.2 metadata = (result.meta or {})[FAST_AGENT_SHELL_PROCESS_METADATA] assert "resource_snapshot" not in metadata @@ -1465,7 +1466,7 @@ async def test_continuous_output_still_yields_at_foreground_ceiling() -> None: result = await runtime.execute({"command": "chatty-build"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -1494,7 +1495,7 @@ async def test_running_poll_with_new_output_is_not_suppressed() -> None: assert result.content is not None assert isinstance(result.content[0], TextContent) assert "still working" in result.content[0].text - assert getattr(result, "_suppress_display", True) is False + assert tool_result_display_metadata(result).get("suppress_display") is False process_metadata = (result.meta or {})[FAST_AGENT_SHELL_PROCESS_METADATA] assert process_metadata["process_yield_reason"] == "nonblocking" await runtime.terminate_process({"process_id": "process-1"}) @@ -1610,7 +1611,7 @@ async def test_terminate_process_is_not_blocked_by_quiet_poll_wait() -> None: ) poll_result = await asyncio.wait_for(poll_task, timeout=0.5) - assert terminate_result.isError is False + assert terminate_result.is_error is False assert environment.cancelled is True process_metadata = (poll_result.meta or {})[FAST_AGENT_SHELL_PROCESS_METADATA] assert process_metadata["process_status"] == "terminated" @@ -1702,7 +1703,7 @@ async def test_poll_rejects_non_boolean_wake_on_output() -> None: } ) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert result.content[0].text == ( @@ -1736,7 +1737,7 @@ async def test_background_command_returns_handle_and_terminate_cancels_job() -> terminate_result = await runtime.terminate_process({"process_id": "process-1"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert "os_pid: 4321" in result.content[0].text @@ -1744,7 +1745,7 @@ async def test_background_command_returns_handle_and_terminate_cancels_job() -> assert result_metadata is not None assert result_metadata["os_process_id"] == 4321 assert result_metadata["process_status"] == "running" - assert terminate_result.isError is False + assert terminate_result.is_error is False terminate_metadata = shell_runtime_module.process_result_metadata(terminate_result) assert terminate_metadata == { "process_id": "process-1", @@ -1804,7 +1805,7 @@ async def test_background_deferred_display_exposes_ordered_result() -> None: defer_display_to_tool_result=True, ) - assert getattr(result, "_suppress_display", True) is False + assert tool_result_display_metadata(result).get("suppress_display") is False await runtime.close() @@ -1820,7 +1821,7 @@ async def test_terminate_process_reports_environment_cancellation_failure() -> N result = await runtime.terminate_process({"process_id": "process-1"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "outcome: termination_failed" in result.content[0].text @@ -1850,7 +1851,7 @@ async def test_lifecycle_tool_calls_emit_correlated_progress() -> None: tool_use_id="call-poll", ) - assert result.isError is False + assert result.is_error is False progress_payloads = _extract_progress_payloads(logger) assert [payload["tool_name"] for payload in progress_payloads] == [ "poll_process", @@ -2203,7 +2204,7 @@ async def test_execute_rejects_invalid_argument_payloads() -> None: await runtime.execute({"command": 123}), # type: ignore[dict-item] ] - assert [result.isError for result in invalid_results] == [True, True, True, True] + assert [result.is_error for result in invalid_results] == [True, True, True, True] messages: list[str] = [] for result in invalid_results: assert result.content is not None @@ -2456,7 +2457,7 @@ async def test_execute_honors_per_call_output_byte_limit() -> None: } ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -2481,7 +2482,7 @@ async def test_execute_clamps_oversized_per_call_output_byte_limit() -> None: } ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -2504,7 +2505,7 @@ async def test_execute_handles_overlong_output_lines_without_timeout() -> None: command = f'"{sys.executable}" -c "print(\'x\' * 70000)"' result = await runtime.execute({"command": command}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -2554,7 +2555,7 @@ async def test_execute_returns_when_descendant_keeps_pipe_open( elapsed = time.monotonic() - started assert elapsed < 1 - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -2637,7 +2638,7 @@ async def test_execute_huge_output_exits_cleanly_with_low_byte_limit() -> None: command = f'"{sys.executable}" -c "import sys; sys.stdout.buffer.write(b\'x\' * 5_000_000)"' result = await runtime.execute({"command": command}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text @@ -2661,7 +2662,7 @@ async def test_execute_with_missing_working_directory_returns_actionable_error( result = await runtime.execute({"command": "pwd"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "Shell working directory does not exist" in result.content[0].text @@ -2684,7 +2685,7 @@ async def test_execute_with_file_working_directory_returns_actionable_error( result = await runtime.execute({"command": "pwd"}) - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "Shell working directory is not a directory" in result.content[0].text @@ -2758,7 +2759,7 @@ async def test_execute_no_output_shows_compact_exit_banner_detail() -> None: show_tool_call_id=True, ) - assert result.isError is False + assert result.is_error is False rendered = capture.get() assert "exit code 0" in rendered assert "(no output)" in rendered @@ -2875,7 +2876,7 @@ async def test_execute_live_display_truncates_with_head_and_tail_windows() -> No with console.console.capture() as capture: result = await runtime.execute({"command": command}) - assert result.isError is False + assert result.is_error is False rendered = capture.get() assert "out-01" in rendered assert "out-02" in rendered @@ -2905,13 +2906,14 @@ async def test_execute_deferred_display_suppresses_live_console_output() -> None defer_display_to_tool_result=True, ) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert "hello" in result.content[0].text assert "process exit code was 0" in result.content[0].text - assert getattr(result, "_suppress_display", True) is False - assert getattr(result, "output_line_count", None) == 1 + metadata = tool_result_display_metadata(result) + assert metadata.get("suppress_display") is False + assert metadata.get("output_line_count") == 1 rendered = capture.get() assert "hello" not in rendered assert "exit code" not in rendered @@ -2927,7 +2929,7 @@ async def test_execute_progress_only_mode_suppresses_live_console_output() -> No with console.console.capture() as capture: result = await runtime.execute({"command": "echo hello"}) - assert result.isError is False + assert result.is_error is False assert result.content is not None assert isinstance(result.content[0], TextContent) assert "hello" in result.content[0].text @@ -2968,7 +2970,7 @@ async def fake_shell(*args, **kwargs): monkeypatch.setattr(progress_display, "paused", _no_progress) result = await runtime.execute({"command": "echo hello"}, tool_use_id="call-123") - assert result.isError is False + assert result.is_error is False progress_payloads = _extract_progress_payloads(logger) assert len(progress_payloads) == 2 @@ -3016,7 +3018,7 @@ async def fail_shell(*args, **kwargs): result = await runtime.execute({"command": "echo hello"}, tool_use_id="call-456") - assert result.isError is True + assert result.is_error is True assert result.content is not None assert isinstance(result.content[0], TextContent) assert "Command execution failed" in result.content[0].text diff --git a/tests/unit/fast_agent/tools/test_tool_sources.py b/tests/unit/fast_agent/tools/test_tool_sources.py index 18424e979..7aed4f58d 100644 --- a/tests/unit/fast_agent/tools/test_tool_sources.py +++ b/tests/unit/fast_agent/tools/test_tool_sources.py @@ -1,6 +1,6 @@ from __future__ import annotations -from mcp.types import Tool +from mcp_types import Tool from fast_agent.tools.tool_sources import ( ACP_FILESYSTEM_TOOL_SOURCE, @@ -12,7 +12,7 @@ def test_set_tool_source_adds_metadata() -> None: - tool = set_tool_source(Tool(name="read_text_file", inputSchema={}), SHELL_TOOL_SOURCE) + tool = set_tool_source(Tool(name="read_text_file", input_schema={}), SHELL_TOOL_SOURCE) assert tool.meta == {FAST_AGENT_TOOL_SOURCE_META: SHELL_TOOL_SOURCE} @@ -20,7 +20,7 @@ def test_set_tool_source_adds_metadata() -> None: def test_tool_source_reads_metadata() -> None: tool = Tool( name="read_text_file", - inputSchema={}, + input_schema={}, _meta={FAST_AGENT_TOOL_SOURCE_META: ACP_FILESYSTEM_TOOL_SOURCE}, ) @@ -30,7 +30,7 @@ def test_tool_source_reads_metadata() -> None: def test_tool_source_ignores_unknown_metadata_value() -> None: tool = Tool( name="read_text_file", - inputSchema={}, + input_schema={}, _meta={FAST_AGENT_TOOL_SOURCE_META: "unknown"}, ) diff --git a/tests/unit/fast_agent/types/test_conversation_summary.py b/tests/unit/fast_agent/types/test_conversation_summary.py index bff3ce64e..bb838a306 100644 --- a/tests/unit/fast_agent/types/test_conversation_summary.py +++ b/tests/unit/fast_agent/types/test_conversation_summary.py @@ -3,7 +3,7 @@ import json import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from fast_agent.constants import FAST_AGENT_TIMING from fast_agent.types import ConversationSummary, PromptMessageExtended @@ -88,11 +88,11 @@ def test_tool_calls(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Sunny")], - isError=False, + is_error=False, ), "call_2": CallToolResult( content=[TextContent(type="text", text="72°F")], - isError=False, + is_error=False, ), }, ), @@ -141,15 +141,15 @@ def test_tool_errors(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Success")], - isError=False, + is_error=False, ), "call_2": CallToolResult( content=[TextContent(type="text", text="Error: failed")], - isError=True, + is_error=True, ), "call_3": CallToolResult( content=[TextContent(type="text", text="Error: timeout")], - isError=True, + is_error=True, ), }, ), @@ -184,7 +184,7 @@ def test_multiple_tool_call_rounds(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Data received")], - isError=False, + is_error=False, ), }, ), @@ -207,11 +207,11 @@ def test_multiple_tool_call_rounds(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Processed")], - isError=False, + is_error=False, ), "call_3": CallToolResult( content=[TextContent(type="text", text="Error")], - isError=True, + is_error=True, ), }, ), @@ -249,7 +249,7 @@ def test_model_dump(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Done")], - isError=False, + is_error=False, ), }, ), @@ -290,7 +290,7 @@ def test_unknown_tool_id_in_results(): tool_results={ "unknown_call": CallToolResult( content=[TextContent(type="text", text="Error")], - isError=True, + is_error=True, ), }, ), diff --git a/tests/unit/fast_agent/types/test_message_search.py b/tests/unit/fast_agent/types/test_message_search.py index ca23bf8a9..5abed8a58 100644 --- a/tests/unit/fast_agent/types/test_message_search.py +++ b/tests/unit/fast_agent/types/test_message_search.py @@ -3,7 +3,7 @@ import re import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, CallToolResult, TextContent from fast_agent.types import ( PromptMessageExtended, @@ -65,7 +65,7 @@ def test_search_messages_tool_results_scope(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: abc123def")], - isError=False, + is_error=False, ), }, ), @@ -74,7 +74,7 @@ def test_search_messages_tool_results_scope(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Task completed successfully")], - isError=False, + is_error=False, ), }, ), @@ -142,7 +142,7 @@ def test_search_messages_all_scope(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="error: Not found")], - isError=True, + is_error=True, ), }, ), @@ -160,7 +160,7 @@ def test_search_messages_regex_pattern(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: abc123")], - isError=False, + is_error=False, ), }, ), @@ -169,7 +169,7 @@ def test_search_messages_regex_pattern(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Job started: def456")], - isError=False, + is_error=False, ), }, ), @@ -178,7 +178,7 @@ def test_search_messages_regex_pattern(): tool_results={ "call_3": CallToolResult( content=[TextContent(type="text", text="Task completed")], - isError=False, + is_error=False, ), }, ), @@ -215,7 +215,7 @@ def test_find_matches(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: abc123def")], - isError=False, + is_error=False, ), }, ), @@ -224,7 +224,7 @@ def test_find_matches(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Job started: xyz789ghi")], - isError=False, + is_error=False, ), }, ), @@ -269,7 +269,7 @@ def test_extract_first_basic(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: abc123def")], - isError=False, + is_error=False, ), }, ), @@ -288,7 +288,7 @@ def test_extract_first_with_capture_group(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: abc123def")], - isError=False, + is_error=False, ), }, ), @@ -324,7 +324,7 @@ def test_extract_first_multiple_messages(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: first123")], - isError=False, + is_error=False, ), }, ), @@ -333,7 +333,7 @@ def test_extract_first_multiple_messages(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Job started: second456")], - isError=False, + is_error=False, ), }, ), @@ -351,7 +351,7 @@ def test_extract_last_basic(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Status: pending")], - isError=False, + is_error=False, ), }, ), @@ -360,7 +360,7 @@ def test_extract_last_basic(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Status: running")], - isError=False, + is_error=False, ), }, ), @@ -369,7 +369,7 @@ def test_extract_last_basic(): tool_results={ "call_3": CallToolResult( content=[TextContent(type="text", text="Status: completed")], - isError=False, + is_error=False, ), }, ), @@ -388,7 +388,7 @@ def test_extract_last_with_capture_group(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: abc123")], - isError=False, + is_error=False, ), }, ), @@ -397,7 +397,7 @@ def test_extract_last_with_capture_group(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Job started: xyz789")], - isError=False, + is_error=False, ), }, ), @@ -429,7 +429,7 @@ def test_extract_last_single_match(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Job started: single123")], - isError=False, + is_error=False, ), }, ), @@ -447,7 +447,7 @@ def test_extract_first_vs_extract_last(): tool_results={ "call_1": CallToolResult( content=[TextContent(type="text", text="Version: 1.0")], - isError=False, + is_error=False, ), }, ), @@ -456,7 +456,7 @@ def test_extract_first_vs_extract_last(): tool_results={ "call_2": CallToolResult( content=[TextContent(type="text", text="Version: 2.0")], - isError=False, + is_error=False, ), }, ), @@ -465,7 +465,7 @@ def test_extract_first_vs_extract_last(): tool_results={ "call_3": CallToolResult( content=[TextContent(type="text", text="Version: 3.0")], - isError=False, + is_error=False, ), }, ), diff --git a/tests/unit/fast_agent/ui/test_agent_completer.py b/tests/unit/fast_agent/ui/test_agent_completer.py index 9853240ac..fa77d4425 100644 --- a/tests/unit/fast_agent/ui/test_agent_completer.py +++ b/tests/unit/fast_agent/ui/test_agent_completer.py @@ -8,8 +8,8 @@ from typing import TYPE_CHECKING, cast import pytest -from mcp.types import Completion as MCPCompletion -from mcp.types import ListToolsResult, ResourceTemplate, TextContent, Tool +from mcp_types import Completion as MCPCompletion +from mcp_types import ListToolsResult, ResourceTemplate, TextContent, Tool from prompt_toolkit.completion import CompleteEvent, Completion from prompt_toolkit.document import Document @@ -83,9 +83,9 @@ async def list_tools(self) -> ListToolsResult: Tool( name="search", description="Search indexed documents", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), - Tool(name="read_file", inputSchema={"type": "object"}), + Tool(name="read_file", input_schema={"type": "object"}), ] ) @@ -126,12 +126,12 @@ class _MentionAggregatorStub: def __init__(self) -> None: self._templates = { "demo": [ - ResourceTemplate(name="repo", uriTemplate="repo://items/{id}"), - ResourceTemplate(name="repo_pair", uriTemplate="repo://items/{owner}/{repo}"), - ResourceTemplate(name="repo_resource", uriTemplate="repo://items/{resourceId}"), + ResourceTemplate(name="repo", uri_template="repo://items/{id}"), + ResourceTemplate(name="repo_pair", uri_template="repo://items/{owner}/{repo}"), + ResourceTemplate(name="repo_resource", uri_template="repo://items/{resourceId}"), ResourceTemplate( name="repo_contents", - uriTemplate="repo://{owner}/{repo}/contents{/path*}", + uri_template="repo://{owner}/{repo}/contents{/path*}", ), ] } diff --git a/tests/unit/fast_agent/ui/test_agent_info.py b/tests/unit/fast_agent/ui/test_agent_info.py index a5dd04895..42bfa20b1 100644 --- a/tests/unit/fast_agent/ui/test_agent_info.py +++ b/tests/unit/fast_agent/ui/test_agent_info.py @@ -3,7 +3,7 @@ from typing import Any, cast import pytest -from mcp.types import ListToolsResult, Tool +from mcp_types import ListToolsResult, Tool from fast_agent.interfaces import AgentProtocol from fast_agent.ui.prompt import agent_info @@ -179,8 +179,8 @@ class Aggregator: async def list_tools(self) -> ListToolsResult: return ListToolsResult( tools=[ - Tool(name="hf.tool_1", inputSchema={}), - Tool(name="hf.tool_2", inputSchema={}), + Tool(name="hf.tool_1", input_schema={}), + Tool(name="hf.tool_2", input_schema={}), ] ) diff --git a/tests/unit/fast_agent/ui/test_alert_flag_extraction.py b/tests/unit/fast_agent/ui/test_alert_flag_extraction.py index ae5fd6ed4..2bc0ec464 100644 --- a/tests/unit/fast_agent/ui/test_alert_flag_extraction.py +++ b/tests/unit/fast_agent/ui/test_alert_flag_extraction.py @@ -1,7 +1,7 @@ import json from typing import Literal -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import ( FAST_AGENT_ALERT_CHANNEL, diff --git a/tests/unit/fast_agent/ui/test_citation_display.py b/tests/unit/fast_agent/ui/test_citation_display.py index 4f442d825..d67fabd15 100644 --- a/tests/unit/fast_agent/ui/test_citation_display.py +++ b/tests/unit/fast_agent/ui/test_citation_display.py @@ -1,6 +1,6 @@ import json -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import ANTHROPIC_CITATIONS_CHANNEL, ANTHROPIC_SERVER_TOOLS_CHANNEL from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/unit/fast_agent/ui/test_console_display_empty_additional_message.py b/tests/unit/fast_agent/ui/test_console_display_empty_additional_message.py index 272bcafa4..e1473fc44 100644 --- a/tests/unit/fast_agent/ui/test_console_display_empty_additional_message.py +++ b/tests/unit/fast_agent/ui/test_console_display_empty_additional_message.py @@ -1,7 +1,7 @@ import asyncio import json -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from rich.console import Group from rich.syntax import Syntax from rich.text import Text @@ -216,7 +216,7 @@ def test_reasoning_only_turn_does_not_emit_extra_gap_before_tool_result() -> Non }, stop_reason=LlmStopReason.TOOL_USE, ) - tool_result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + tool_result = CallToolResult(content=[TextContent(type="text", text="ok")], is_error=False) async def _render() -> str: with console.console.capture() as capture: diff --git a/tests/unit/fast_agent/ui/test_display_suppression.py b/tests/unit/fast_agent/ui/test_display_suppression.py index cb741ba5a..5b1ab09bd 100644 --- a/tests/unit/fast_agent/ui/test_display_suppression.py +++ b/tests/unit/fast_agent/ui/test_display_suppression.py @@ -1,6 +1,6 @@ from __future__ import annotations -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from rich.text import Text from fast_agent.ui.console_display import ConsoleDisplay, ParallelAgentDisplayResult @@ -36,7 +36,7 @@ def test_progress_only_display_suppresses_status_and_chat(capsys) -> None: display.show_tool_result( CallToolResult( content=[TextContent(type="text", text="hidden result")], - isError=False, + is_error=False, ), tool_name="echo", ) diff --git a/tests/unit/fast_agent/ui/test_elicitation_form.py b/tests/unit/fast_agent/ui/test_elicitation_form.py index 0eb058c0b..93615aea6 100644 --- a/tests/unit/fast_agent/ui/test_elicitation_form.py +++ b/tests/unit/fast_agent/ui/test_elicitation_form.py @@ -10,7 +10,7 @@ from fast_agent.ui.elicitation_form import ElicitationForm, FormatValidator, SimpleStringValidator if TYPE_CHECKING: - from mcp.types import ElicitRequestedSchema + from mcp_types import ElicitRequestedSchema def test_elicitation_form_creates_widgets_for_common_field_types() -> None: diff --git a/tests/unit/fast_agent/ui/test_history_actions.py b/tests/unit/fast_agent/ui/test_history_actions.py index 16563ee04..10cde31fd 100644 --- a/tests/unit/fast_agent/ui/test_history_actions.py +++ b/tests/unit/fast_agent/ui/test_history_actions.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, TextContent from fast_agent.constants import ANTHROPIC_SERVER_TOOLS_CHANNEL, FAST_AGENT_TOOL_METADATA from fast_agent.mcp.prompt_message_extended import PromptMessageExtended diff --git a/tests/unit/fast_agent/ui/test_history_display.py b/tests/unit/fast_agent/ui/test_history_display.py index b73b2dfa4..dad9b8243 100644 --- a/tests/unit/fast_agent/ui/test_history_display.py +++ b/tests/unit/fast_agent/ui/test_history_display.py @@ -2,7 +2,7 @@ from types import SimpleNamespace from typing import Any, cast -from mcp.types import CallToolResult, ImageContent, TextContent +from mcp_types import CallToolResult, ImageContent, TextContent from rich.console import Console from fast_agent.constants import ANTHROPIC_SERVER_TOOLS_CHANNEL, FAST_AGENT_TIMING, FAST_AGENT_USAGE @@ -27,7 +27,7 @@ def test_extract_tool_result_summary_returns_named_fields_for_mixed_content() -> result = CallToolResult( content=[ TextContent(type="text", text="hello\nworld"), - ImageContent(type="image", data="abc", mimeType="image/png"), + ImageContent(type="image", data="abc", mime_type="image/png"), ] ) diff --git a/tests/unit/fast_agent/ui/test_interactive_prompt_agent_commands.py b/tests/unit/fast_agent/ui/test_interactive_prompt_agent_commands.py index e18c1788b..f204a103c 100644 --- a/tests/unit/fast_agent/ui/test_interactive_prompt_agent_commands.py +++ b/tests/unit/fast_agent/ui/test_interactive_prompt_agent_commands.py @@ -5,7 +5,7 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, CallToolResult, TextContent +from mcp_types import CallToolRequestParams, CallToolResult, TextContent from fast_agent.agents.agent_types import AgentType from fast_agent.agents.tool_runner import HistoryRollbackState @@ -657,7 +657,7 @@ class _State: text="**The user interrupted this tool call**", ) ], - isError=True, + is_error=True, ) }, ), diff --git a/tests/unit/fast_agent/ui/test_interactive_prompt_hash_send.py b/tests/unit/fast_agent/ui/test_interactive_prompt_hash_send.py index d47961510..267bebdd7 100644 --- a/tests/unit/fast_agent/ui/test_interactive_prompt_hash_send.py +++ b/tests/unit/fast_agent/ui/test_interactive_prompt_hash_send.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING import pytest -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.tools.shell_runtime import ShellRuntime from fast_agent.ui.interactive_prompt import InteractivePrompt if TYPE_CHECKING: - from mcp.types import PromptMessage + from mcp_types import PromptMessage from fast_agent.types import PromptMessageExtended diff --git a/tests/unit/fast_agent/ui/test_interactive_prompt_resource_mentions.py b/tests/unit/fast_agent/ui/test_interactive_prompt_resource_mentions.py index 90c0ad9b6..2129c9a10 100644 --- a/tests/unit/fast_agent/ui/test_interactive_prompt_resource_mentions.py +++ b/tests/unit/fast_agent/ui/test_interactive_prompt_resource_mentions.py @@ -4,8 +4,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest -from mcp.types import EmbeddedResource, ReadResourceResult, TextResourceContents -from pydantic import AnyUrl +from mcp_types import EmbeddedResource, ReadResourceResult, TextResourceContents from fast_agent.agents.agent_types import AgentType from fast_agent.core.agent_app import AgentRefreshResult @@ -27,8 +26,8 @@ async def get_resource(self, resource_uri: str, namespace: str | None = None): return ReadResourceResult( contents=[ TextResourceContents( - uri=AnyUrl(resource_uri), - mimeType="text/plain", + uri=resource_uri, + mime_type="text/plain", text="payload", ) ] diff --git a/tests/unit/fast_agent/ui/test_mcp_ui_utils.py b/tests/unit/fast_agent/ui/test_mcp_ui_utils.py index 4a0ede3ad..75b8798e9 100644 --- a/tests/unit/fast_agent/ui/test_mcp_ui_utils.py +++ b/tests/unit/fast_agent/ui/test_mcp_ui_utils.py @@ -2,8 +2,7 @@ from pathlib import Path -from mcp.types import EmbeddedResource, TextResourceContents -from pydantic import AnyUrl +from mcp_types import EmbeddedResource, TextResourceContents from fast_agent.ui import mcp_ui_utils @@ -91,8 +90,8 @@ def test_remote_dom_placeholder_escapes_resource_fields( resource = EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("ui://card/"), - mimeType="application/vnd.mcp-ui.remote-dom", + uri="ui://card/", + mime_type="application/vnd.mcp-ui.remote-dom", text="", ), ) diff --git a/tests/unit/fast_agent/ui/test_message_display_helpers.py b/tests/unit/fast_agent/ui/test_message_display_helpers.py index c849e6128..b6081dfb2 100644 --- a/tests/unit/fast_agent/ui/test_message_display_helpers.py +++ b/tests/unit/fast_agent/ui/test_message_display_helpers.py @@ -1,5 +1,5 @@ import pytest -from mcp.types import CallToolRequest, CallToolRequestParams, ImageContent, TextContent +from mcp_types import CallToolRequest, CallToolRequestParams, ImageContent, TextContent from fast_agent.constants import FAST_AGENT_SAFETY_DETAILS from fast_agent.types import PromptMessageExtended @@ -249,7 +249,7 @@ def test_extract_user_attachments_includes_local_image_source_uri() -> None: image = ImageContent( type="image", data="ZmFrZQ==", - mimeType="image/png", + mime_type="image/png", ) image.meta = {"fast_agent_source_uri": "file:///tmp/photo.png"} message = PromptMessageExtended( @@ -261,11 +261,11 @@ def test_extract_user_attachments_includes_local_image_source_uri() -> None: def test_extract_user_local_image_previews_only_includes_file_sources() -> None: - local_image = ImageContent(type="image", data="ZmFrZQ==", mimeType="image/png") + local_image = ImageContent(type="image", data="ZmFrZQ==", mime_type="image/png") local_image.meta = {"fast_agent_source_uri": "file:///tmp/photo.png"} - remote_image = ImageContent(type="image", data="ZmFrZQ==", mimeType="image/png") + remote_image = ImageContent(type="image", data="ZmFrZQ==", mime_type="image/png") remote_image.meta = {"fast_agent_source_uri": "https://example.test/photo.png"} - inline_image = ImageContent(type="image", data="ZmFrZQ==", mimeType="image/png") + inline_image = ImageContent(type="image", data="ZmFrZQ==", mime_type="image/png") message = PromptMessageExtended( role="user", content=[local_image, remote_image, inline_image], @@ -278,9 +278,9 @@ def test_extract_user_local_image_previews_only_includes_file_sources() -> None: def test_build_user_message_image_previews_combines_messages() -> None: - first_image = ImageContent(type="image", data="ZmFrZQ==", mimeType="image/png") + first_image = ImageContent(type="image", data="ZmFrZQ==", mime_type="image/png") first_image.meta = {"fast_agent_source_uri": "file:///tmp/one.png"} - second_image = ImageContent(type="image", data="ZmFrZQ==", mimeType="image/png") + second_image = ImageContent(type="image", data="ZmFrZQ==", mime_type="image/png") second_image.meta = {"fast_agent_source_uri": "file:///tmp/two.png"} previews = build_user_message_image_previews( @@ -294,7 +294,7 @@ def test_build_user_message_image_previews_combines_messages() -> None: def test_build_user_message_display_prefers_original_text_metadata() -> None: - image = ImageContent(type="image", data="ZmFrZQ==", mimeType="image/png") + image = ImageContent(type="image", data="ZmFrZQ==", mime_type="image/png") image.meta = {"fast_agent_source_uri": "file:///tmp/photo.png"} text = PromptMessageExtended.model_validate( { diff --git a/tests/unit/fast_agent/ui/test_read_text_file_tool_display.py b/tests/unit/fast_agent/ui/test_read_text_file_tool_display.py index 1709bfc71..1e937b181 100644 --- a/tests/unit/fast_agent/ui/test_read_text_file_tool_display.py +++ b/tests/unit/fast_agent/ui/test_read_text_file_tool_display.py @@ -1,6 +1,7 @@ -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.config import Settings, ShellSettings +from fast_agent.mcp.tool_result_metadata import update_tool_result_display_metadata from fast_agent.ui import console from fast_agent.ui.console_display import ConsoleDisplay from fast_agent.ui.tool_display import ToolDisplay @@ -51,7 +52,7 @@ def test_read_text_file_result_truncates_with_head_and_more_lines_note() -> None display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=4))) output_lines = [f"line-{i}" for i in range(1, 8)] result_text = "\n".join(output_lines) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) with console.console.capture() as capture: display.show_tool_result( @@ -75,7 +76,7 @@ def test_read_text_file_result_skips_truncation_when_only_two_lines_over_limit() display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=4))) output_lines = [f"line-{i}" for i in range(1, 7)] result_text = "\n".join(output_lines) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) with console.console.capture() as capture: display.show_tool_result( @@ -94,7 +95,7 @@ def test_read_text_file_result_hides_content_when_line_limit_is_zero() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=0))) output_lines = [f"line-{i}" for i in range(1, 4)] result_text = "\n".join(output_lines) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) with console.console.capture() as capture: display.show_tool_result( @@ -112,10 +113,15 @@ def test_read_text_file_result_hides_content_when_line_limit_is_zero() -> None: def test_read_text_file_result_shows_no_lines_message_when_empty() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=4))) - result = CallToolResult(content=[TextContent(type="text", text="")], isError=False) - setattr(result, "read_text_file_path", "/tmp/one/two/example.py") - setattr(result, "read_text_file_line", 300) - setattr(result, "read_text_file_limit", 80) + result = CallToolResult(content=[TextContent(type="text", text="")], is_error=False) + update_tool_result_display_metadata( + result, + { + "read_text_file_path": "/tmp/one/two/example.py", + "read_text_file_line": 300, + "read_text_file_limit": 80, + }, + ) with console.console.capture() as capture: display.show_tool_result( @@ -159,7 +165,7 @@ def test_read_text_file_markdown_wrap_uses_language_from_path() -> None: def test_read_text_file_result_header_uses_preview_status() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=3))) result_text = "\n".join(["a", "b", "c", "d", "e"]) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) with console.console.capture() as capture: display.show_tool_result( @@ -177,8 +183,11 @@ def test_read_text_file_result_header_uses_preview_status() -> None: def test_read_text_file_result_header_shows_short_path_when_available() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=3))) result_text = "\n".join(["a", "b", "c", "d", "e"]) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) - setattr(result, "read_text_file_path", "/tmp/one/two/example.py") + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) + update_tool_result_display_metadata( + result, + {"read_text_file_path": "/tmp/one/two/example.py"}, + ) with console.console.capture() as capture: display.show_tool_result( @@ -195,10 +204,15 @@ def test_read_text_file_result_header_shows_short_path_when_available() -> None: def test_read_text_file_result_header_includes_offset_and_limit_when_available() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=3))) result_text = "\n".join(["a", "b", "c", "d", "e"]) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) - setattr(result, "read_text_file_path", "/tmp/one/two/example.py") - setattr(result, "read_text_file_line", 93) - setattr(result, "read_text_file_limit", 30) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) + update_tool_result_display_metadata( + result, + { + "read_text_file_path": "/tmp/one/two/example.py", + "read_text_file_line": 93, + "read_text_file_limit": 30, + }, + ) with console.console.capture() as capture: display.show_tool_result( diff --git a/tests/unit/fast_agent/ui/test_resource_mentions.py b/tests/unit/fast_agent/ui/test_resource_mentions.py index cc034b1aa..9044f330a 100644 --- a/tests/unit/fast_agent/ui/test_resource_mentions.py +++ b/tests/unit/fast_agent/ui/test_resource_mentions.py @@ -4,7 +4,7 @@ from io import BytesIO import pytest -from mcp.types import ( +from mcp_types import ( EmbeddedResource, ImageContent, ReadResourceResult, @@ -12,7 +12,6 @@ TextResourceContents, ) from PIL import Image -from pydantic import AnyUrl from fast_agent.ui.prompt.resource_mentions import ( ResourceMentionError, @@ -28,8 +27,8 @@ async def get_resource(self, resource_uri: str, namespace: str | None = None): return ReadResourceResult( contents=[ TextResourceContents( - uri=AnyUrl(resource_uri), - mimeType="text/plain", + uri=resource_uri, + mime_type="text/plain", text=f"{namespace}:{resource_uri}", ) ] @@ -204,7 +203,7 @@ async def test_resolve_mentions_converts_local_ppm_to_png(tmp_path) -> None: assert len(prompt.content) == 2 image = prompt.content[1] assert isinstance(image, ImageContent) - assert image.mimeType == "image/png" + assert image.mime_type == "image/png" assert base64.b64decode(image.data).startswith(b"\x89PNG\r\n\x1a\n") @@ -222,7 +221,7 @@ async def test_resolve_mentions_detects_pillow_image_without_known_mime(tmp_path assert len(prompt.content) == 2 image = prompt.content[1] assert isinstance(image, ImageContent) - assert image.mimeType == "image/png" + assert image.mime_type == "image/png" assert base64.b64decode(image.data).startswith(b"\x89PNG\r\n\x1a\n") @@ -235,7 +234,7 @@ async def test_resolve_mentions_builds_remote_url_resource_link_without_agent_su assert isinstance(prompt.content[1], ResourceLink) assert str(prompt.content[1].uri) == "https://example.com/image.png" - assert prompt.content[1].mimeType == "image/png" + assert prompt.content[1].mime_type == "image/png" @pytest.mark.asyncio @@ -248,7 +247,7 @@ async def test_resolve_mentions_infers_image_type_from_query_and_defaults_to_ima prompt = build_prompt_with_resources(parsed.text, resolved) assert isinstance(prompt.content[1], ResourceLink) - assert prompt.content[1].mimeType == "image/jpeg" + assert prompt.content[1].mime_type == "image/jpeg" @pytest.mark.asyncio @@ -259,7 +258,7 @@ async def test_resolve_mentions_keeps_unknown_remote_type_questionable() -> None prompt = build_prompt_with_resources(parsed.text, resolved) assert isinstance(prompt.content[1], ResourceLink) - assert prompt.content[1].mimeType == "application/octet-stream" + assert prompt.content[1].mime_type == "application/octet-stream" @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py index 040071ccf..5b0307afa 100644 --- a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py +++ b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py @@ -1,7 +1,8 @@ -from mcp.types import CallToolResult, TextContent +from mcp_types import CallToolResult, TextContent from fast_agent.config import Settings, ShellSettings from fast_agent.constants import FAST_AGENT_SHELL_PROCESS_METADATA +from fast_agent.mcp.tool_result_metadata import update_tool_result_display_metadata from fast_agent.ui import console from fast_agent.ui.console_display import ConsoleDisplay from fast_agent.ui.progress_display import progress_display @@ -12,9 +13,9 @@ def test_shell_tool_result_uses_styled_exit_line() -> None: display = ConsoleDisplay() result = CallToolResult( content=[TextContent(type="text", text="hello\nprocess exit code was 0")], - isError=False, + is_error=False, ) - setattr(result, "output_line_count", 1) + update_tool_result_display_metadata(result, {"output_line_count": 1}) with console.console.capture() as capture: display.show_tool_result( @@ -36,7 +37,7 @@ def test_shell_tool_result_no_output_adds_no_output_detail() -> None: display = ConsoleDisplay() result = CallToolResult( content=[TextContent(type="text", text="process exit code was 0")], - isError=False, + is_error=False, ) with console.console.capture() as capture: @@ -64,9 +65,9 @@ def test_poll_process_result_hides_process_metadata_and_keeps_exit_banner() -> N text="finished\nprocess_id: process-1\nprocess exit code was 0", ) ], - isError=False, + is_error=False, ) - setattr(result, "output_line_count", 1) + update_tool_result_display_metadata(result, {"output_line_count": 1}) with console.console.capture() as capture: display.show_tool_result(result, name="dev", tool_name="poll_process") @@ -97,7 +98,7 @@ def test_running_process_result_uses_compact_lifecycle_line() -> None: ), ) ], - isError=False, + is_error=False, ) with console.console.capture() as capture: @@ -126,7 +127,7 @@ def test_quiet_running_poll_result_is_not_rendered() -> None: ), ) ], - isError=False, + is_error=False, ) result.meta = { FAST_AGENT_SHELL_PROCESS_METADATA: { @@ -147,7 +148,7 @@ def test_process_non_poll_result_is_rendered() -> None: display = ConsoleDisplay() result = CallToolResult( content=[TextContent(type="text", text="Process is still running.")], - isError=False, + is_error=False, ) with console.console.capture() as capture: @@ -193,7 +194,7 @@ def test_terminate_process_result_uses_compact_lifecycle_line() -> None: text="process_id: process-3\noutcome: terminated", ) ], - isError=False, + is_error=False, ) with console.console.capture() as capture: @@ -208,8 +209,8 @@ def test_shell_tool_result_truncates_with_head_and_tail_windows() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=6))) output_lines = [f"out-{i:02d}" for i in range(1, 11)] result_text = "\n".join([*output_lines, "process exit code was 0"]) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) - setattr(result, "output_line_count", len(output_lines)) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) + update_tool_result_display_metadata(result, {"output_line_count": len(output_lines)}) with console.console.capture() as capture: display.show_tool_result( @@ -238,7 +239,7 @@ def test_shell_tool_result_parallel_deferred_uses_source_line_count() -> None: display = ConsoleDisplay(config=Settings(shell_execution=ShellSettings(output_display_lines=4))) output_lines = [f"line-{i}" for i in range(1, 13)] result_text = "\n".join([*output_lines, "process exit code was 0"]) - result = CallToolResult(content=[TextContent(type="text", text=result_text)], isError=False) + result = CallToolResult(content=[TextContent(type="text", text=result_text)], is_error=False) with console.console.capture() as capture: display.show_tool_result( @@ -266,12 +267,8 @@ def test_tool_result_prefers_structured_content_over_many_text_blocks() -> None: TextContent(type="text", text='{"id":"a"}'), TextContent(type="text", text='{"id":"b"}'), ], - isError=False, - ) - setattr( - result, - "structuredContent", - {"result": [{"id": "a"}, {"id": "b"}]}, + structured_content={"result": [{"id": "a"}, {"id": "b"}]}, + is_error=False, ) with console.console.capture() as capture: @@ -293,17 +290,13 @@ def test_tool_result_prefers_structured_content_when_text_blocks_disagree() -> N TextContent(type="text", text='{"id":"a","status":"closed"}'), TextContent(type="text", text='{"id":"b","status":"pending"}'), ], - isError=False, - ) - setattr( - result, - "structuredContent", - { + structured_content={ "result": [ {"id": "a", "status": "open"}, {"id": "b", "status": "escalated"}, ] }, + is_error=False, ) with console.console.capture() as capture: @@ -321,10 +314,10 @@ def test_structured_tool_result_shows_transport_timing_and_structured_footer() - display = ConsoleDisplay() result = CallToolResult( content=[TextContent(type="text", text='{"ok": true}')], - structuredContent={"ok": True}, - isError=False, + structured_content={"ok": True}, + is_error=False, ) - setattr(result, "transport_channel", "post-json") + update_tool_result_display_metadata(result, {"transport_channel": "post-json"}) with console.console.capture() as capture: display.show_tool_result( diff --git a/tests/unit/fast_agent/ui/test_terminal_images.py b/tests/unit/fast_agent/ui/test_terminal_images.py index e27361629..dbaf0f7c3 100644 --- a/tests/unit/fast_agent/ui/test_terminal_images.py +++ b/tests/unit/fast_agent/ui/test_terminal_images.py @@ -2,7 +2,7 @@ from types import SimpleNamespace import pytest -from mcp.types import ImageContent, TextContent +from mcp_types import ImageContent, TextContent from fast_agent.command_actions.models import PluginCommandActionImage from fast_agent.config import LoggerSettings, Settings, TerminalImageSettings @@ -30,7 +30,7 @@ def _image_content() -> ImageContent: return ImageContent( type="image", data=base64.b64encode(_PNG_BYTES).decode("ascii"), - mimeType="image/png", + mime_type="image/png", ) @@ -82,11 +82,11 @@ def test_tool_result_images_render_without_console_display_state() -> None: def test_tool_result_media_preview_is_display_only() -> None: - from mcp.types import CallToolResult + from mcp_types import CallToolResult result = CallToolResult( content=[TextContent(type="text", text="Staged image for the next model call.")], - isError=False, + is_error=False, ) set_tool_result_media_preview(result, [_image_content()]) diff --git a/tests/unit/llm/provider/test_error_utils.py b/tests/unit/llm/provider/test_error_utils.py index c62724970..bf01f2f86 100644 --- a/tests/unit/llm/provider/test_error_utils.py +++ b/tests/unit/llm/provider/test_error_utils.py @@ -1,6 +1,6 @@ """Tests for LLM provider error utilities.""" -from mcp.types import TextContent +from mcp_types import TextContent from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL from fast_agent.llm.provider.error_utils import build_stream_failure_response diff --git a/tests/unit/scripts/test_validate_token_accounting.py b/tests/unit/scripts/test_validate_token_accounting.py index a727ccb1f..3245f2e53 100644 --- a/tests/unit/scripts/test_validate_token_accounting.py +++ b/tests/unit/scripts/test_validate_token_accounting.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING import pytest -from mcp.types import ( +from mcp_types import ( CallToolRequest, CallToolRequestParams, CallToolResult, @@ -127,7 +127,7 @@ def _write_session(tmp_path: Path) -> tuple[SessionManager, Path]: tool_results={ call_id: CallToolResult( content=[TextContent(type="text", text="ok")], - isError=False, + is_error=False, ) }, ), diff --git a/uv.lock b/uv.lock index 3b9fe0a8d..c55a90d4e 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,11 @@ members = [ "fast-agent-mcp", "hf-inference-acp", ] +constraints = [ + { name = "fastmcp-slim", specifier = "==4.0.0a2" }, + { name = "mcp", specifier = "==2.0.0b2" }, + { name = "mcp-types", specifier = "==2.0.0b2" }, +] [[package]] name = "a2a-sdk" @@ -880,6 +885,7 @@ dependencies = [ { name = "jsonschema" }, { name = "keyring" }, { name = "mcp" }, + { name = "mcp-types" }, { name = "mslex" }, { name = "openai", extra = ["aiohttp"] }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -979,14 +985,15 @@ requires-dist = [ { name = "duckdb", marker = "extra == 'parquet'", specifier = ">=1.4" }, { name = "email-validator", specifier = "==2.3.0" }, { name = "fastapi", specifier = "==0.136.3" }, - { name = "fastmcp", specifier = "==3.4.4" }, + { name = "fastmcp", specifier = "==4.0.0a2" }, { name = "filelock", specifier = "==3.29.1" }, { name = "gepa", marker = "extra == 'gepa'", specifier = ">=0.1.1" }, { name = "google-genai", specifier = "==2.10.0" }, { name = "huggingface-hub", specifier = "==1.24.0" }, { name = "jsonschema", specifier = "==4.26.0" }, { name = "keyring", specifier = "==25.7.0" }, - { name = "mcp", specifier = "==1.28.1" }, + { name = "mcp", specifier = "==2.0.0b2" }, + { name = "mcp-types", specifier = "==2.0.0b2" }, { name = "mslex", specifier = "==1.3.0" }, { name = "numpy", marker = "extra == 'privacy'", specifier = ">=2" }, { name = "numpy", marker = "extra == 'privacy-gpu'", specifier = ">=2" }, @@ -1059,21 +1066,22 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.4" +version = "4.0.0a2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/e4/03878943f2308e391cb6fa644e9731ae667a43fdd10e943f49bcb212a119/fastmcp-4.0.0a2.tar.gz", hash = "sha256:85400ada91a87c3c31796b7deb3592a055d1623ef7f5417c05ff11688bbc4f3c", size = 41970107, upload-time = "2026-07-24T01:02:41.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, + { url = "https://files.pythonhosted.org/packages/b4/03/5de3e24041f7ae3b7b98a6c4756c42af862ea6dd8a7b46a8cd2dca0055c4/fastmcp-4.0.0a2-py3-none-any.whl", hash = "sha256:4c66d02688330bc84a106b3b944b71cf4943d5cf1e009413d2c3195dac3ed376", size = 8037, upload-time = "2026-07-24T01:02:38.049Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.4" +version = "4.0.0a2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mcp-types" }, { name = "platformdirs" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -1081,16 +1089,16 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/26/cc9a459f6abf5321eb73959c5cbc9884893eace1b855173c9832c9fa73a3/fastmcp_slim-4.0.0a2.tar.gz", hash = "sha256:9486717b1de8ddc624b48bc2bf66a9c451ad04052429b14b5a2add6c987e27cd", size = 649518, upload-time = "2026-07-24T01:01:12.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, + { url = "https://files.pythonhosted.org/packages/f3/87/d62fc901f167237e6266156031b790c36f848d5038ad268fabc35b2957c9/fastmcp_slim-4.0.0a2-py3-none-any.whl", hash = "sha256:325ae95e311f07a759a699aef5fb43b2c217f02e947f680ab6f928d4764edf56", size = 819056, upload-time = "2026-07-24T01:01:10.551Z" }, ] [package.optional-dependencies] client = [ { name = "authlib" }, { name = "exceptiongroup" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, @@ -1101,7 +1109,7 @@ server = [ { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -1405,6 +1413,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -1478,6 +1499,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + [[package]] name = "huggingface-hub" version = "1.24.0" @@ -1923,13 +1960,14 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -1941,9 +1979,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" }, ] [[package]] @@ -3785,6 +3836,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.44" From ad3297e2b654847d9765092590029e2166e14eb8 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:22:08 +0100 Subject: [PATCH 2/3] Refactor MCP runtime around SDK high-level client --- .../mcp-2026-07-28-functional-deficits.md | 13 +- .../mcp-2026-07-28-impact-assessment.md | 19 + .../mcp-2026-07-28-real-server-validation.md | 11 +- src/fast_agent/cli/commands/auth.py | 26 +- src/fast_agent/mcp/client_callback_runtime.py | 282 +++++++ src/fast_agent/mcp/client_connection.py | 319 +++++++ src/fast_agent/mcp/elicitation_handlers.py | 98 ++- src/fast_agent/mcp/gen_client.py | 31 +- .../mcp/helpers/server_config_helpers.py | 42 - src/fast_agent/mcp/interfaces.py | 75 +- .../mcp/mcp_agent_client_session.py | 775 ------------------ src/fast_agent/mcp/mcp_aggregator.py | 260 +++--- src/fast_agent/mcp/mcp_connection_manager.py | 230 +++--- src/fast_agent/mcp/sampling.py | 96 +-- src/fast_agent/mcp_server_registry.py | 59 +- .../api/test_mcp_oauth_auto_escalation.py | 25 +- .../mcp_2026/subscription_server.py | 19 +- .../mcp_2026/test_modern_subscriptions.py | 17 + .../mcp/test_client_callback_runtime.py | 176 ++++ .../mcp/test_elicitation_handlers.py | 50 +- ...est_mcp_aggregator_metadata_passthrough.py | 66 +- .../mcp/test_mcp_aggregator_nonpersistent.py | 276 +++---- .../mcp/test_mcp_connection_manager.py | 78 +- .../mcp/test_server_config_helpers.py | 37 - .../mcp/test_server_session_terminated.py | 69 -- .../mcp/test_url_elicitation_required.py | 39 - 26 files changed, 1400 insertions(+), 1788 deletions(-) create mode 100644 src/fast_agent/mcp/client_callback_runtime.py create mode 100644 src/fast_agent/mcp/client_connection.py delete mode 100644 src/fast_agent/mcp/helpers/server_config_helpers.py delete mode 100644 src/fast_agent/mcp/mcp_agent_client_session.py create mode 100644 tests/unit/fast_agent/mcp/test_client_callback_runtime.py delete mode 100644 tests/unit/fast_agent/mcp/test_server_config_helpers.py delete mode 100644 tests/unit/fast_agent/mcp/test_server_session_terminated.py diff --git a/docs-internal/mcp-2026-07-28-functional-deficits.md b/docs-internal/mcp-2026-07-28-functional-deficits.md index 0c0161c53..302974bbc 100644 --- a/docs-internal/mcp-2026-07-28-functional-deficits.md +++ b/docs-internal/mcp-2026-07-28-functional-deficits.md @@ -11,13 +11,20 @@ restored, replaced, or explicitly removed as a product decision. | MCP2-003 | Modern health | Periodic MCP ping is disabled after modern negotiation. | MCP 2026-07-28 removes `ping`. | Use negotiation, operation success/error, subscription state, and stdio process state. | | MCP2-004 | HTTP OAuth events | Existing detailed OAuth timing and paste-fallback behavior still depends on the migrated SDK provider subclass. | FastMCP v4's public OAuth API does not expose equivalent event and paste hooks. | Replace the subclass when upstream exposes lifecycle callbacks; retain focused compatibility tests meanwhile. | | MCP2-005 | Transport result channel | Tool results no longer receive a synthetic `transport_channel` attribute. | Request IDs and response channels are dispatcher internals in SDK v2. | Record operation timing/status externally; request a public diagnostics hook if exact channel attribution is required. | -| MCP2-006 | Negotiation API | Dual-era negotiation imports the SDK's `mcp.client._probe.negotiate_auto`. | SDK v2 implements negotiation centrally but does not export the helper from a public package surface. | Replace the import when the SDK exports negotiation or when fast-agent adopts high-level `mcp.client.Client`. | | MCP2-007 | MCP OpenTelemetry auto-instrumentation | `opentelemetry-instrumentation-mcp==0.62.1` is not activated. | It patches the removed SDK v1 symbol `mcp.client.streamable_http.streamablehttp_client` and crashes telemetry setup with SDK v2. | Re-enable `McpInstrumentor` when an SDK-v2-compatible release is available; fast-agent's own MCP progress and timing telemetry remains active. | -| MCP3-001 | MRTR low-level API | Fast-agent uses the SDK's `_input_required.run_input_required_driver` with `ClientSession`. | SDK v2 exposes automatic MRTR only on its high-level `Client`; the existing connection manager still owns low-level sessions. | Move the connection boundary to public `mcp.client.Client` once its configured stdio/OAuth adapters can replace the remaining fast-agent lifecycle hooks. | -| MCP3-002 | Response caching | Protocol response caching is not yet enabled on low-level `ClientSession` operations. | SDK v2's cache is integrated with high-level `Client`, not `ClientSession`; duplicating it is undesirable. | Complete the high-level client boundary and use its default per-client in-memory cache. Do not add an aggregator protocol cache. | | MCP3-003 | Subscription replay | A dropped modern subscription may lose events before reconnection. | MCP subscriptions have no replay. | The supervisor reopens with bounded backoff; perform full derived-index refresh after reconnection when upstream exposes an acknowledgment/restart barrier suitable for this lifecycle. | | MCP3-004 | Resource subscriptions | The initial supervisor listens for list changes but does not yet maintain a dynamic set of individual resource URIs. | Fast-agent only materializes selected Skybridge resources and the filter is immutable. | Rebuild observed URIs after resource-list refresh and restart the listener with that set. | | MCP3-005 | Shared cache | No persistent or cross-principal protocol response cache is provided. | Safe sharing requires verified principal partitioning and stable server identity. | Keep future shared caching opt-in and default `share_public=False`. | | MCP3-006 | FastMCP elicitation examples | FastMCP v4 `Context.elicit()` still uses a standalone server-to-client request, which SDK v2 rejects on a negotiated `2026-07-28` connection. Three existing custom-handler integration cases therefore fail even though the SDK-native MRTR path succeeds. | FastMCP `4.0.0a2` does not expose the SDK `MCPServer` resolver/InputRequired API. | Migrate the examples to SDK `MCPServer` + `Resolve(Elicit(...))`, or adopt the FastMCP MRTR API when one is public. | | MCP3-007 | State-transfer example | The current harness MCP server exposes agent tools but no `_history` prompt, so `examples/mcp/state-transfer` can send a turn but cannot transfer it through the documented prompt. | The prompt surface was absent before this protocol migration and is not restored by the FastMCP v4 update. | Add a session-scoped history prompt to `HarnessMCPAppServer` and notify prompt-list subscribers when the first history entry is created. | | MCP3-008 | Modern stdio subscriptions | `subscriptions/listen` is reported as unsupported on modern stdio connections. | SDK v2's server rejects the request with `subscriptions/listen is not served over this transport`; its streaming response contract is implemented for Streamable HTTP. | Retain ordinary stdio list-change notifications if the modern specification adds them, or enable listen when the SDK server transport supports streaming responses over stdio. | +| MCP3-009 | Sampling tool choice | Fast-agent advertises `sampling.tools`, but the provider-neutral sampling bridge does not yet enforce MCP `toolChoice.mode="required"` or `"none"` uniformly across providers. | Fast-agent's generic `RequestParams` has no provider-neutral tool-choice field; provider wire shapes differ. | Add a provider-neutral tool-choice policy at the LLM boundary, then map it in each provider before expanding sampling support further. | +| MCP3-010 | FastMCP sampling/roots examples | Existing FastMCP integration servers call `Context.session.create_message()` and `list_roots()` as standalone server-initiated requests. After modern negotiation the SDK rejects them because 2026-07-28 requires embedded MRTR input requests. | FastMCP v4's context helpers still expose the legacy standalone path; SDK `MCPServer` exposes modern `Resolve(Sample(...))` and `Resolve(ListRoots())`. | Migrate the examples and integration simulators to SDK resolver inputs, or adopt equivalent FastMCP resolver APIs when public. Keep legacy-server callback coverage separately. | + +## Resolved during the high-level client refactor + +| ID | Resolution | +| --- | --- | +| MCP2-006 | All negotiation now runs through public `mcp.client.Client(mode="auto")`; fast-agent no longer imports `_probe.negotiate_auto`. | +| MCP3-001 | Tool, prompt, and resource MRTR now use the SDK high-level client; the custom `*_complete` methods and private input-required driver were removed. | +| MCP3-002 | Attached runtimes use the SDK's default per-client response cache. Request-scoped clients disable caching to preserve authorization isolation. Modern subscription refresh uses `Client.listen()`, including the SDK cache-eviction barrier. | diff --git a/docs-internal/mcp-2026-07-28-impact-assessment.md b/docs-internal/mcp-2026-07-28-impact-assessment.md index 01c126df8..21cc6eb91 100644 --- a/docs-internal/mcp-2026-07-28-impact-assessment.md +++ b/docs-internal/mcp-2026-07-28-impact-assessment.md @@ -17,6 +17,25 @@ The checked-out Python SDK `main` is still based on v1.24 and has local changes. The v2 assessment therefore uses `upstream/main` and the `v2.0.0b2` tag without altering the checkout. +## Implementation outcome + +Fast-agent now owns configured server runtimes while the public +`mcp.client.Client` owns protocol behavior: + +- `Client(mode="auto")` performs discovery and legacy fallback; +- high-level tool, prompt, and resource methods drive MRTR; +- attached runtimes use the SDK response cache; +- on-demand, request-authenticated clients disable caching; +- `Client.listen()` owns modern subscription demultiplexing and cache eviction; +- fast-agent callbacks are composed in `MCPClientCallbackRuntime` rather than + attached to a `ClientSession` subclass; +- the custom `MCPAgentClientSession`, session factory, manual negotiation, and + private input-required driver have been removed. + +`MCPConnectionManager` remains as a product runtime owner for attach/detach, +stdio processes, OAuth UX, startup budgets, replacement, and status. It no +longer represents a pool or durable protocol-session identity. + ## Executive assessment Supporting MCP 2026-07-28 is a protocol-era migration, not a version constant diff --git a/docs-internal/mcp-2026-07-28-real-server-validation.md b/docs-internal/mcp-2026-07-28-real-server-validation.md index 3fc3118c0..1cccdb7de 100644 --- a/docs-internal/mcp-2026-07-28-real-server-validation.md +++ b/docs-internal/mcp-2026-07-28-real-server-validation.md @@ -34,6 +34,11 @@ Streamable HTTP: - legacy server-initiated form elicitation through a deterministic callback; - legacy sampling through the passthrough model callback. +The legacy sampling probe was repeated after replacing the custom +`ClientSession` boundary with public `mcp.client.Client`: the +`trigger-sampling-request` tool completed successfully through the SDK callback +table on negotiated `2025-11-25`. + Both transports correctly negotiated the real server as: ```text @@ -61,12 +66,14 @@ Verified behaviors: `Resolve(Elicit(...))`; - automatic client callback dispatch and retry to a complete tool result; - HTTP `subscriptions/listen`; -- tool-list change delivery and fast-agent cache refresh. +- SDK response-cache hits on repeated `tools/list` calls; +- tool-list change delivery with SDK cache eviction before fast-agent refresh. The external `server-everything` and Hugging Face checks above are manual network/process probes. Durable SDK-v2 integration coverage under `tests/integration/mcp_2026/` covers modern negotiation, tool/prompt/resource -operations, no legacy ping activity, MRTR, and HTTP tool-list subscriptions. +operations, no legacy ping activity, MRTR, response caching, and HTTP +tool-list subscriptions. ## Hugging Face MCP diff --git a/src/fast_agent/cli/commands/auth.py b/src/fast_agent/cli/commands/auth.py index 0d941cd03..787eeea45 100644 --- a/src/fast_agent/cli/commands/auth.py +++ b/src/fast_agent/cli/commands/auth.py @@ -275,8 +275,7 @@ async def _run_login_session( # Use appropriate transport; connect and initialize a minimal session. if resolved_transport == "http": import httpx2 - from mcp.client._probe import negotiate_auto - from mcp.client.session import ClientSession + from mcp.client import Client from mcp.client.streamable_http import streamable_http_client async with ( @@ -285,27 +284,22 @@ async def _run_login_session( auth=provider, follow_redirects=True, ) as http_client, - streamable_http_client(cfg.url or "", http_client=http_client) as ( - read_stream, - write_stream, + Client( + streamable_http_client(cfg.url or "", http_client=http_client), + mode="auto", + cache=False, ), - ClientSession(read_stream, write_stream) as session, ): - await negotiate_auto(session) return True if resolved_transport == "sse": - from mcp.client._probe import negotiate_auto - from mcp.client.session import ClientSession + from mcp.client import Client from mcp.client.sse import sse_client - async with ( - sse_client(cfg.url or "", cfg.headers, auth=provider) as ( - read_stream, - write_stream, - ), - ClientSession(read_stream, write_stream) as session, + async with Client( + sse_client(cfg.url or "", cfg.headers, auth=provider), + mode="auto", + cache=False, ): - await negotiate_auto(session) return True return False except Exception as e: diff --git a/src/fast_agent/mcp/client_callback_runtime.py b/src/fast_agent/mcp/client_callback_runtime.py new file mode 100644 index 000000000..43c516848 --- /dev/null +++ b/src/fast_agent/mcp/client_callback_runtime.py @@ -0,0 +1,282 @@ +"""Fast-agent callback configuration for an MCP client connection.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from importlib.metadata import version +from typing import TYPE_CHECKING, cast + +from mcp.client.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ServerEvent, + ToolsListChanged, +) +from mcp_types import ( + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + ErrorData, + Implementation, + ListRootsResult, + ProgressNotification, + Root, + SamplingCapability, + SamplingToolsCapability, +) +from pydantic import FileUrl + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.core.logging.logger import get_logger +from fast_agent.mcp.elicitation_factory import ( + resolve_elicitation_handler, + resolve_global_elicitation_mode, +) +from fast_agent.mcp.elicitation_handlers import ( + forms_elicitation_handler, + make_forms_elicitation_handler, +) +from fast_agent.mcp.sampling import resolve_auto_sampling_enabled, sample + +if TYPE_CHECKING: + from mcp.client.session import ( + ClientRequestContext, + ElicitationFnT, + ListRootsFnT, + MessageHandlerFnT, + SamplingFnT, + ) + + from fast_agent.config import MCPServerSettings + from fast_agent.context import Context + from fast_agent.mcp.mcp_aggregator import MCPAggregator + from fast_agent.mcp.url_elicitation_required import URLElicitationDisplayItem + +logger = get_logger(__name__) + +type ToolListChangedCallback = Callable[[str], Awaitable[None]] + + +@dataclass(slots=True) +class MCPClientCallbackRuntime: + """Compose fast-agent callbacks and capabilities for an MCP SDK client. + + The object deliberately owns client-specific state rather than attaching it + to an SDK protocol object. Its callback attributes are passed directly to + ``mcp.client.Client``. + """ + + server_name: str | None + server_config: MCPServerSettings | None + agent_model: str | None = None + agent_name: str | None = None + api_key: str | None = None + custom_elicitation_handler: ElicitationFnT | None = None + aggregator: MCPAggregator | None = None + context: Context | None = None + tool_list_changed_callback: ToolListChangedCallback | None = None + effective_elicitation_mode: str = field(init=False) + client_info: Implementation = field(init=False) + list_roots_callback: ListRootsFnT | None = field(init=False) + sampling_callback: SamplingFnT | None = field(init=False) + sampling_capabilities: SamplingCapability | None = field(init=False) + elicitation_callback: ElicitationFnT | None = field(init=False) + message_handler: MessageHandlerFnT = field(init=False) + _pending_url_elicitations: list[URLElicitationDisplayItem] = field( + init=False, + default_factory=list, + ) + + def __post_init__(self) -> None: + self.client_info = self._client_implementation() + self.list_roots_callback = self._make_list_roots_callback() + self.sampling_callback = self._make_sampling_callback() + self.sampling_capabilities = self._make_sampling_capabilities() + self.elicitation_callback = self._resolve_elicitation_handler() + self.effective_elicitation_mode = self._resolve_effective_elicitation_mode() + self.message_handler = self._handle_message + + @property + def display_server_name(self) -> str: + """Return the configured server name suitable for UI and notifications.""" + if self.server_name: + return self.server_name + if self.server_config and self.server_config.name: + return self.server_config.name + return "unknown" + + def _client_implementation(self) -> Implementation: + if self.server_config and self.server_config.implementation: + return self.server_config.implementation + return Implementation(name="fast-agent-mcp", version=version("fast-agent-mcp") or "dev") + + def _make_list_roots_callback(self) -> ListRootsFnT | None: + if self.server_config is None or not self.server_config.roots: + return None + roots = self.server_config.roots + + async def list_roots(context: ClientRequestContext) -> ListRootsResult: + del context + return ListRootsResult( + roots=[ + Root(uri=FileUrl(root.server_uri_alias or root.uri), name=root.name) + for root in roots + ] + ) + + return cast("ListRootsFnT", list_roots) + + def _make_sampling_callback(self) -> SamplingFnT | None: + if self.server_config and self.server_config.sampling: + return self._sampling_callback + if self._should_enable_auto_sampling(): + return self._sampling_callback + return None + + async def _sampling_callback( + self, + context: ClientRequestContext, + params: CreateMessageRequestParams, + ) -> CreateMessageResult | CreateMessageResultWithTools | ErrorData: + del context + return await sample( + params, + server_name=self.display_server_name, + server_config=self.server_config, + agent_model=self.agent_model, + api_key=self.api_key, + app_context=self._app_context(), + ) + + def _make_sampling_capabilities(self) -> SamplingCapability | None: + if self.sampling_callback is None: + return None + return SamplingCapability(tools=SamplingToolsCapability()) + + def _resolve_elicitation_handler(self) -> ElicitationFnT | None: + if self.custom_elicitation_handler is not None: + return self.custom_elicitation_handler + + app_context = self._app_context() + if app_context is not None and app_context.config is not None: + handler = resolve_elicitation_handler( + AgentConfig( + name=self.agent_name or "unknown", + model=self.agent_model or "unknown", + elicitation_handler=None, + ), + app_context.config, + self.server_config, + ) + if handler is forms_elicitation_handler: + return self._forms_elicitation_handler() + return handler + + if self.server_config is not None: + return None + + return self._forms_elicitation_handler() + + def _forms_elicitation_handler(self) -> ElicitationFnT: + server_info = None + if self.server_config is not None and self.server_config.command is not None: + server_info = {"command": self.server_config.command} + return make_forms_elicitation_handler( + agent_name=self.agent_name or "Unknown Agent", + server_name=self.display_server_name, + server_info=server_info, + queue_url_elicitation=self.queue_url_elicitation, + ) + + def _resolve_effective_elicitation_mode(self) -> str: + if self.server_config and self.server_config.elicitation is not None: + return self.server_config.elicitation.mode or "forms" + if self.elicitation_callback is None: + return "none" + + app_context = self._app_context() + if app_context is not None and app_context.config is not None: + return resolve_global_elicitation_mode(app_context.config) or "forms" + return "forms" + + def _should_enable_auto_sampling(self) -> bool: + return resolve_auto_sampling_enabled(self._app_context()) + + def _app_context(self) -> Context | None: + if self.context is not None: + return self.context + try: + from fast_agent.context import get_current_context + + return get_current_context() + except Exception: + return None + + def queue_url_elicitation( + self, + *, + message: str, + url: str, + elicitation_id: str | None, + ) -> bool: + """Queue URL elicitation for attachment to the active request result.""" + from fast_agent.mcp.url_elicitation_required import URLElicitationDisplayItem + + self._pending_url_elicitations.append( + URLElicitationDisplayItem( + message=message, + url=url, + elicitation_id=elicitation_id or "", + ) + ) + return True + + def consume_pending_url_elicitations(self) -> list[URLElicitationDisplayItem]: + """Return and clear URL elicitations received during the active request.""" + items = self._pending_url_elicitations + self._pending_url_elicitations = [] + return items + + def discard_pending_url_elicitations(self) -> None: + """Discard URL elicitations when the associated request fails.""" + self._pending_url_elicitations = [] + + async def _handle_message(self, message: object) -> None: + if isinstance(message, ToolsListChanged) and self.tool_list_changed_callback is not None: + asyncio.create_task(self._notify_tool_list_changed()) + if self.aggregator is not None and not isinstance(message, ProgressNotification | Exception): + asyncio.create_task(self._notify_server_notification(message)) + + async def _notify_tool_list_changed(self) -> None: + if self.tool_list_changed_callback is None: + return + try: + await self.tool_list_changed_callback(self.display_server_name) + except Exception as exc: + logger.error(f"Error in tool list changed callback: {exc}") + + async def _notify_server_notification(self, notification: object) -> None: + if self.aggregator is None: + return + callback = self.aggregator.server_notification_callback + if callback is None: + return + try: + await callback(self.display_server_name, notification) + except Exception as exc: + logger.warning( + f"Error in server notification callback for '{self.display_server_name}': {exc}" + ) + + async def handle_subscription_event(self, event: ServerEvent) -> None: + """Refresh aggregator indexes for a modern subscription event.""" + if self.aggregator is None: + return + if isinstance(event, ToolsListChanged): + await self.aggregator._handle_tool_list_changed(self.display_server_name) + elif isinstance(event, PromptsListChanged): + await self.aggregator._fetch_and_cache_prompts(self.display_server_name) + elif isinstance(event, ResourcesListChanged): + await self.aggregator._refresh_server_resources(self.display_server_name) diff --git a/src/fast_agent/mcp/client_connection.py b/src/fast_agent/mcp/client_connection.py new file mode 100644 index 000000000..4cf7312ea --- /dev/null +++ b/src/fast_agent/mcp/client_connection.py @@ -0,0 +1,319 @@ +"""High-level MCP client connection owned by fast-agent.""" + +from __future__ import annotations + +import json +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Literal, TypeVar + +from mcp.client import Client, Transport +from mcp.shared.exceptions import MCPError +from mcp_types import ( + CallToolResult, + CompleteResult, + GetPromptResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + PaginatedRequestParams, + PromptReference, + ReadResourceResult, + Request, + RequestParamsMeta, + ResourceTemplateReference, + TextContent, +) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from fast_agent.core.exceptions import ServerSessionTerminatedError +from fast_agent.mcp.tool_result_metadata import set_url_elicitation_required_payload +from fast_agent.mcp.url_elicitation_required import ( + URLElicitationRequiredDisplayPayload, + build_url_elicitation_required_display_payload, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable + from contextlib import AbstractAsyncContextManager + from types import TracebackType + + from mcp.shared.dispatcher import ProgressFnT + + from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime + +URL_ELICITATION_REQUIRED = -32042 +T = TypeVar("T") + + +class DirectoryReadRequestParams(PaginatedRequestParams): + """Parameters for the SEP-2640 directory-read extension.""" + + uri: str + + +class DirectoryReadRequest( + Request[DirectoryReadRequestParams, Literal["resources/directory/read"]] +): + """Request for the SEP-2640 directory-read extension.""" + + method: Literal["resources/directory/read"] = "resources/directory/read" + params: DirectoryReadRequestParams + + +class MCPClientConnection: + """Compose the SDK client with fast-agent callback and extension behavior.""" + + def __init__( + self, + transport: Transport, + callbacks: MCPClientCallbackRuntime, + *, + read_timeout_seconds: float | None = None, + cache: bool = True, + ) -> None: + self.callbacks = callbacks + self.client = Client( + transport, + mode="auto", + read_timeout_seconds=read_timeout_seconds, + sampling_callback=callbacks.sampling_callback, + sampling_capabilities=callbacks.sampling_capabilities, + list_roots_callback=callbacks.list_roots_callback, + elicitation_callback=callbacks.elicitation_callback, + message_handler=callbacks.message_handler, + client_info=callbacks.client_info, + cache=None if cache else False, + ) + + async def __aenter__(self) -> MCPClientConnection: + await self.client.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.client.__aexit__(exc_type, exc_val, exc_tb) + + @property + def session(self): + """Expose the SDK session only for diagnostics and extension requests.""" + return self.client.session + + @property + def protocol_version(self) -> str: + return self.client.protocol_version + + @property + def server_info(self): + return self.client.server_info + + @property + def server_capabilities(self): + return self.client.server_capabilities + + @property + def instructions(self) -> str | None: + return self.client.instructions + + @property + def discover_result(self): + return self.client.session.discover_result + + @property + def effective_elicitation_mode(self) -> str: + return self.callbacks.effective_elicitation_mode + + def listen( + self, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: tuple[str, ...] = (), + ): + return self.client.listen( + tools_list_changed=tools_list_changed, + prompts_list_changed=prompts_list_changed, + resources_list_changed=resources_list_changed, + resource_subscriptions=resource_subscriptions, + ) + + async def ping(self, read_timeout_seconds: float | None = None) -> object: + """Send a legacy health ping; modern runtimes never call this.""" + del read_timeout_seconds + return await self.client.send_ping() + + async def list_tools( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListToolsResult: + return await self.client.list_tools(cursor=cursor, meta=meta) + + async def list_prompts( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListPromptsResult: + return await self.client.list_prompts(cursor=cursor, meta=meta) + + async def list_resources( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListResourcesResult: + return await self.client.list_resources(cursor=cursor, meta=meta) + + async def list_resource_templates( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListResourceTemplatesResult: + return await self.client.list_resource_templates(cursor=cursor, meta=meta) + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + meta: RequestParamsMeta | None = None, + ) -> CallToolResult: + return await self._interactive_operation( + "tools/call", + self.client.call_tool( + name, + arguments, + read_timeout_seconds, + progress_callback, + meta=meta, + ), + ) + + async def read_resource( + self, + uri: str, + *, + meta: RequestParamsMeta | None = None, + ) -> ReadResourceResult: + return await self._interactive_operation( + "resources/read", + self.client.read_resource(uri, meta=meta), + ) + + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + meta: RequestParamsMeta | None = None, + ) -> GetPromptResult: + return await self._interactive_operation( + "prompts/get", + self.client.get_prompt(name, arguments, meta=meta), + ) + + async def complete( + self, + ref: ResourceTemplateReference | PromptReference, + argument: dict[str, str], + context_arguments: dict[str, str] | None = None, + ) -> CompleteResult: + return await self.client.complete(ref, argument, context_arguments) + + async def read_directory( + self, + uri: str, + *, + cursor: str | None = None, + ) -> ListResourcesResult: + request = DirectoryReadRequest( + params=DirectoryReadRequestParams(uri=uri, cursor=cursor) + ) + return await self.client.session.send_request(request, ListResourcesResult) + + async def _interactive_operation(self, method: str, operation: Awaitable[T]) -> T: + self.callbacks.discard_pending_url_elicitations() + try: + result = await operation + except MCPError as exc: + self.callbacks.discard_pending_url_elicitations() + if ( + exc.code == ServerSessionTerminatedError.SESSION_TERMINATED_CODE + and self.protocol_version not in MODERN_PROTOCOL_VERSIONS + ): + raise ServerSessionTerminatedError( + server_name=self.callbacks.display_server_name, + details="Server returned 404 - runtime may need replacement", + ) from exc + if exc.code == URL_ELICITATION_REQUIRED: + payload = build_url_elicitation_required_display_payload( + exc.data, + server_name=self.callbacks.display_server_name, + request_method=method, + ) + set_url_elicitation_required_payload(exc, payload) + raise + + self._attach_pending_url_elicitations(result, method) + self._attach_url_elicitation_result(result, method) + return result + + def _attach_pending_url_elicitations(self, result: object, method: str) -> None: + items = self.callbacks.consume_pending_url_elicitations() + if not items: + return + payload = URLElicitationRequiredDisplayPayload( + server_name=self.callbacks.display_server_name, + request_method=method, + elicitations=items, + issues=[], + ) + set_url_elicitation_required_payload(result, payload) + + def _attach_url_elicitation_result(self, result: object, method: str) -> None: + if not isinstance(result, CallToolResult) or not result.is_error or not result.content: + return + first = result.content[0] + text = first.text if isinstance(first, TextContent) else None + marker = "fast-agent-url-elicitation-required:" + if not isinstance(text, str) or marker not in text: + return + _, _, encoded = text.partition(marker) + with suppress(json.JSONDecodeError): + data = json.loads(encoded.strip()) + if isinstance(data, dict): + payload = build_url_elicitation_required_display_payload( + data, + server_name=self.callbacks.display_server_name, + request_method=method, + ) + set_url_elicitation_required_payload(result, payload) + + +class MCPTransportAdapter: + """Adapt fast-agent's legacy three-value transport context to SDK Transport.""" + + def __init__(self, context: AbstractAsyncContextManager[tuple[Any, Any, Any]]) -> None: + self._context = context + + async def __aenter__(self): + read_stream, write_stream, _session_id = await self._context.__aenter__() + return read_stream, write_stream + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + return await self._context.__aexit__(exc_type, exc_val, exc_tb) diff --git a/src/fast_agent/mcp/elicitation_handlers.py b/src/fast_agent/mcp/elicitation_handlers.py index d85c4a326..c78ce2a3e 100644 --- a/src/fast_agent/mcp/elicitation_handlers.py +++ b/src/fast_agent/mcp/elicitation_handlers.py @@ -6,7 +6,7 @@ import json from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from mcp_types import ( ElicitRequestFormParams, @@ -17,19 +17,30 @@ ) from fast_agent.core.logging.logger import get_logger -from fast_agent.mcp.helpers.server_config_helpers import get_server_config from fast_agent.utils.text import strip_casefold if TYPE_CHECKING: from collections.abc import Callable - from mcp.client.session import ClientRequestContext + from mcp.client.session import ClientRequestContext, ElicitationFnT from fast_agent.human_input.types import HumanInputResponse - from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession ElicitationContent = dict[str, str | int | float | bool | list[str] | None] + +class URLElicitationQueue(Protocol): + """Accept URL elicitation details for deferred request-result display.""" + + def __call__( + self, + *, + message: str, + url: str, + elicitation_id: str | None, + ) -> bool: ... + + logger = get_logger(__name__) _BOOLEAN_RESPONSE_VALUES = { "yes": True, @@ -48,7 +59,7 @@ class _ElicitationContextInfo: agent_name: str server_name: str server_info: dict[str, str] | None - session: MCPAgentClientSession | None + queue_url_elicitation: URLElicitationQueue | None def _required_schema_fields(requested_schema: dict[str, Any]) -> list[str]: @@ -162,32 +173,18 @@ async def auto_cancel_elicitation_handler( return ElicitResult(action="cancel") -def _mcp_agent_session( - context: ClientRequestContext, -) -> MCPAgentClientSession | None: - from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession - - session = context.session - if isinstance(session, MCPAgentClientSession): - return session - return None - - def _elicitation_context_info( - context: ClientRequestContext, + *, + agent_name: str, + server_name: str, + server_info: dict[str, str] | None = None, + queue_url_elicitation: URLElicitationQueue | None = None, ) -> _ElicitationContextInfo: - server_config = get_server_config(context) - session = _mcp_agent_session(context) - server_name = server_config.name if server_config and server_config.name else "Unknown Server" - server_info = ( - {"command": server_config.command} if server_config and server_config.command else None - ) - agent_name = session.agent_name if session and session.agent_name else "Unknown Agent" return _ElicitationContextInfo( agent_name=agent_name, server_name=server_name, server_info=server_info, - session=session, + queue_url_elicitation=queue_url_elicitation, ) @@ -202,8 +199,8 @@ def _handle_url_elicitation( ) queued = False - if context_info.session is not None: - queued = context_info.session.queue_url_elicitation_for_active_request( + if context_info.queue_url_elicitation is not None: + queued = context_info.queue_url_elicitation( message=params.message, url=url, elicitation_id=elicitation_id, @@ -291,21 +288,48 @@ async def _handle_form_elicitation( return ElicitResult(action="accept", content=content) +def make_forms_elicitation_handler( + *, + agent_name: str, + server_name: str, + server_info: dict[str, str] | None = None, + queue_url_elicitation: URLElicitationQueue | None = None, +) -> "ElicitationFnT": + """Build a forms handler without relying on SDK session-attached state.""" + context_info = _elicitation_context_info( + agent_name=agent_name, + server_name=server_name, + server_info=server_info, + queue_url_elicitation=queue_url_elicitation, + ) + + async def handler( + context: ClientRequestContext, + params: ElicitRequestParams, + ) -> ElicitResult | ErrorData: + del context + logger.info(f"Eliciting response for params: {params}") + if isinstance(params, ElicitRequestURLParams): + return _handle_url_elicitation(params, context_info) + + try: + return await _handle_form_elicitation(params, context_info) + except (KeyboardInterrupt, EOFError, TimeoutError): + return ElicitResult(action="cancel") + + return handler + + async def forms_elicitation_handler( context: ClientRequestContext, params: ElicitRequestParams -) -> ElicitResult: +) -> ElicitResult | ErrorData: """ Combined elicitation handler supporting both form and URL modes. For form mode: Uses interactive forms-based UI for data collection. For URL mode: Displays the URL inline for out-of-band user interaction. """ - logger.info(f"Eliciting response for params: {params}") - context_info = _elicitation_context_info(context) - if isinstance(params, ElicitRequestURLParams): - return _handle_url_elicitation(params, context_info) - - try: - return await _handle_form_elicitation(params, context_info) - except (KeyboardInterrupt, EOFError, TimeoutError): - return ElicitResult(action="cancel") + return await make_forms_elicitation_handler( + agent_name="Unknown Agent", + server_name="Unknown Server", + )(context, params) diff --git a/src/fast_agent/mcp/gen_client.py b/src/fast_agent/mcp/gen_client.py index caa80faa2..6a5baa5e6 100644 --- a/src/fast_agent/mcp/gen_client.py +++ b/src/fast_agent/mcp/gen_client.py @@ -1,14 +1,16 @@ -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +from __future__ import annotations -from mcp import ClientSession +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING from fast_agent.core.logging.logger import get_logger -from fast_agent.mcp.interfaces import ( - ClientSessionFactory, - ServerInitializerProtocol, -) -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime + from fast_agent.mcp.client_connection import MCPClientConnection + from fast_agent.mcp.interfaces import ServerInitializerProtocol logger = get_logger(__name__) @@ -17,15 +19,14 @@ async def gen_client( server_name: str, server_registry: ServerInitializerProtocol, - client_session_factory: ClientSessionFactory = MCPAgentClientSession, *, + callback_runtime: MCPClientCallbackRuntime | None = None, trigger_oauth: bool | None = None, -) -> AsyncIterator[ClientSession]: +) -> AsyncIterator[MCPClientConnection]: """ - Create a client session to the specified server. - Handles server startup, initialization, and message receive loop setup. - If required, callers can specify their own message receive loop and ClientSession class constructor to customize further. - For persistent connections, use MCPConnectionManager instead. + Create an on-demand high-level client for the specified server. + Handles server startup through the public high-level ``mcp.client.Client``. + For attached runtimes, use MCPConnectionManager instead. """ if not server_registry: raise ValueError( @@ -34,7 +35,7 @@ async def gen_client( async with server_registry.initialize_server( server_name=server_name, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, trigger_oauth=trigger_oauth, ) as session: yield session diff --git a/src/fast_agent/mcp/helpers/server_config_helpers.py b/src/fast_agent/mcp/helpers/server_config_helpers.py deleted file mode 100644 index bbf03ff2c..000000000 --- a/src/fast_agent/mcp/helpers/server_config_helpers.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Helper functions for type-safe server config access.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol, overload, runtime_checkable - -if TYPE_CHECKING: - from fast_agent.config import MCPServerSettings - from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession - - -@runtime_checkable -class _SessionRequestContext(Protocol): - session: object - - -@overload -def get_server_config(ctx: MCPAgentClientSession) -> MCPServerSettings | None: ... - - -@overload -def get_server_config(ctx: _SessionRequestContext) -> MCPServerSettings | None: ... - - -@overload -def get_server_config(ctx: object) -> MCPServerSettings | None: ... - - -def get_server_config(ctx: object) -> MCPServerSettings | None: - """Extract server config from context if available. - - Supports either an MCPAgentClientSession or an MCP request context carrying one. - """ - from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession - - if isinstance(ctx, MCPAgentClientSession): - return ctx.server_config - - if isinstance(ctx, _SessionRequestContext) and isinstance(ctx.session, MCPAgentClientSession): - return ctx.session.server_config - - return None diff --git a/src/fast_agent/mcp/interfaces.py b/src/fast_agent/mcp/interfaces.py index 3f52e5331..8e100699d 100644 --- a/src/fast_agent/mcp/interfaces.py +++ b/src/fast_agent/mcp/interfaces.py @@ -6,14 +6,10 @@ from contextlib import AbstractAsyncContextManager from typing import ( TYPE_CHECKING, - Any, Protocol, runtime_checkable, ) -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp import ClientSession - from fast_agent.interfaces import ( AgentProtocol, FastAgentLLMProtocol, @@ -24,89 +20,37 @@ ) if TYPE_CHECKING: - from mcp.shared.dispatcher import ProgressFnT from mcp_types import ( - CallToolResult, - GetPromptResult, - ReadResourceResult, - RequestParamsMeta, ServerCapabilities, ) from fast_agent.config import MCPServerSettings - from fast_agent.mcp.transport_tracking import TransportChannelMetrics + from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime + from fast_agent.mcp.client_connection import MCPClientConnection __all__ = [ "AgentProtocol", - "ClientSessionFactory", - "CompletingClientSession", "FastAgentLLMProtocol", "LLMFactoryProtocol", "LlmAgentProtocol", "ModelFactoryFunctionProtocol", "ModelT", - "ServerConnection", "ServerInitializerProtocol", "ServerRegistryProtocol", ] -@runtime_checkable -class CompletingClientSession(Protocol): - """Session operations that resolve modern input-required rounds.""" - - async def call_tool_complete( - self, - name: str, - arguments: dict[str, Any] | None = None, - read_timeout_seconds: float | None = None, - progress_callback: "ProgressFnT | None" = None, - *, - meta: dict[str, Any] | None = None, - ) -> "CallToolResult": ... - - async def read_resource_complete( - self, - uri: str, - *, - meta: "RequestParamsMeta | None" = None, - ) -> "ReadResourceResult": ... - - async def get_prompt_complete( - self, - name: str, - arguments: dict[str, str] | None = None, - *, - meta: "RequestParamsMeta | None" = None, - ) -> "GetPromptResult": ... - - -@runtime_checkable -class ClientSessionFactory(Protocol): - """Protocol for creating client sessions across persistent and temporary connections.""" - - def __call__( - self, - read_stream: MemoryObjectReceiveStream, - write_stream: MemoryObjectSendStream, - read_timeout: float | None, - *, - server_config: "MCPServerSettings | None" = None, - transport_metrics: "TransportChannelMetrics | None" = None, - ) -> ClientSession: ... - - @runtime_checkable class ServerInitializerProtocol(Protocol): - """Protocol for temporary (non-persistent) server connections used by gen_client.""" + """Protocol for on-demand server clients used by gen_client.""" def initialize_server( self, server_name: str, - client_session_factory: ClientSessionFactory | None = None, + callback_runtime: "MCPClientCallbackRuntime | None" = None, trigger_oauth: bool | None = None, - ) -> AbstractAsyncContextManager[ClientSession]: - """Initialize a server and yield a client session.""" + ) -> AbstractAsyncContextManager["MCPClientConnection"]: + """Initialize a server and yield a client connection.""" ... def get_server_capabilities(self, server_name: str) -> "ServerCapabilities | None": @@ -122,10 +66,3 @@ class ServerRegistryProtocol(ServerInitializerProtocol, Protocol): def registry(self) -> dict[str, "MCPServerSettings"]: ... def get_server_config(self, server_name: str) -> "MCPServerSettings | None": ... - - -class ServerConnection(Protocol): - """Protocol for server connection objects returned by MCPConnectionManager.""" - - @property - def session(self) -> ClientSession: ... diff --git a/src/fast_agent/mcp/mcp_agent_client_session.py b/src/fast_agent/mcp/mcp_agent_client_session.py deleted file mode 100644 index 22b30a3e8..000000000 --- a/src/fast_agent/mcp/mcp_agent_client_session.py +++ /dev/null @@ -1,775 +0,0 @@ -""" -A derived client session for the MCP Agent framework. -It adds logging and supports sampling requests. -""" - -from __future__ import annotations - -import asyncio -import json -import sys -from contextlib import suppress -from typing import TYPE_CHECKING, Any, TypeVar, cast - -from mcp import ClientSession -from mcp.client._input_required import run_input_required_driver -from mcp.client.subscriptions import ( - PromptsListChanged, - ResourcesListChanged, - ServerEvent, - ToolsListChanged, -) -from mcp_types import ( - URL_ELICITATION_REQUIRED, - CallToolResult, - ClientRequest, - EmptyResult, - ErrorData, - GetPromptResult, - Implementation, - InputRequest, - InputRequiredResult, - InputResponse, - InputResponses, - ListResourcesResult, - ListRootsResult, - PaginatedRequestParams, - ProgressNotification, - ReadResourceResult, - Request, - RequestParamsMeta, - Root, - SamplingCapability, - SamplingToolsCapability, - ToolListChangedNotification, -) -from pydantic import AnyUrl, BaseModel, FileUrl, TypeAdapter -from typing_extensions import Literal - -from fast_agent.context_dependent import ContextDependent -from fast_agent.core.logging.logger import get_logger -from fast_agent.mcp.helpers.server_config_helpers import get_server_config -from fast_agent.mcp.sampling import resolve_auto_sampling_enabled, sample -from fast_agent.mcp.tool_result_metadata import ( - set_url_elicitation_required_payload, - update_tool_result_display_metadata, - url_elicitation_required_payload, -) -from fast_agent.mcp.url_elicitation_required import ( - URLElicitationDisplayItem, - URLElicitationRequiredDisplayPayload, - build_url_elicitation_required_display_payload, -) -from fast_agent.utils.env import env_flag -from fast_agent.utils.text import strip_casefold - -if TYPE_CHECKING: - from mcp.client.session import ClientRequestContext, ListRootsFnT, SamplingFnT - from mcp.shared.dispatcher import ProgressFnT - from mcp.shared.message import ClientMessageMetadata - - from fast_agent.config import MCPServerSettings - from fast_agent.mcp.transport_tracking import TransportChannelMetrics - -logger = get_logger(__name__) -ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) - - -class DirectoryReadRequestParams(PaginatedRequestParams): - """Parameters for the SEP-2640 ``resources/directory/read`` method.""" - - uri: str - - -class DirectoryReadRequest( - Request[DirectoryReadRequestParams, Literal["resources/directory/read"]] -): - """List the direct children of a directory resource (SEP-2640).""" - - method: Literal["resources/directory/read"] = "resources/directory/read" - params: DirectoryReadRequestParams - - -def _progress_trace_enabled() -> bool: - return env_flag("FAST_AGENT_TRACE_MCP_PROGRESS") - - -def _progress_trace(message: str) -> None: - if not _progress_trace_enabled(): - return - print(f"[mcp-progress-trace] {message}", file=sys.stderr, flush=True) - - -_URL_ELICITATION_RESULT_PREFIX = "fast-agent-url-elicitation-required:" - - -async def list_roots(context: ClientRequestContext) -> ListRootsResult: - """List roots callback that will be called by the MCP library.""" - - if (server_config := get_server_config(context.session)) and server_config.roots: - roots = [ - Root( - uri=FileUrl( - root.server_uri_alias or root.uri, - ), - name=root.name, - ) - for root in server_config.roots - ] - return ListRootsResult(roots=roots) - - return ListRootsResult(roots=[]) - - -class MCPAgentClientSession(ClientSession, ContextDependent): - """ - MCP Agent framework acts as a client to the servers providing tools/resources/prompts for the agent workloads. - This is a simple client session for those server connections, and supports - - handling sampling requests - - notifications - - MCP root configuration - - Developers can extend this class to add more custom functionality as needed - """ - - _pending_url_elicitations: list[URLElicitationDisplayItem] - - def __init__(self, read_stream, write_stream, read_timeout=None, **kwargs) -> None: - custom_elicitation_handler = self._pop_fast_agent_kwargs(kwargs) - self._initialize_session_state() - - fast_agent = self._client_implementation() - list_roots_cb = self._make_list_roots_callback() - sampling_cb = self._make_sampling_callback() - sampling_caps = self._make_sampling_capabilities(sampling_cb) - elicitation_handler = self._resolve_elicitation_handler(custom_elicitation_handler) - self.effective_elicitation_mode = self._resolve_effective_elicitation_mode( - elicitation_handler - ) - self._discard_managed_client_kwargs(kwargs) - - super().__init__( - read_stream, - write_stream, - read_timeout, - **kwargs, - list_roots_callback=list_roots_cb, - sampling_callback=sampling_cb, - sampling_capabilities=sampling_caps, - client_info=fast_agent, - elicitation_callback=elicitation_handler, - message_handler=self._handle_message, - ) - - def _pop_fast_agent_kwargs(self, kwargs: dict[str, Any]) -> Any: - self.session_server_name = kwargs.pop("server_name", None) - self._tool_list_changed_callback = kwargs.pop("tool_list_changed_callback", None) - self._aggregator = kwargs.pop("aggregator", None) - self.server_config: MCPServerSettings | None = kwargs.pop("server_config", None) - self.agent_model: str | None = kwargs.pop("agent_model", None) - self.agent_name: str | None = kwargs.pop("agent_name", None) - self.api_key: str | None = kwargs.pop("api_key", None) - self._context = kwargs.pop("context", None) - self._transport_metrics: TransportChannelMetrics | None = kwargs.pop( - "transport_metrics", None - ) - return kwargs.pop("elicitation_handler", None) - - def _initialize_session_state(self) -> None: - self.effective_elicitation_mode: str | None = "none" - self._offline_notified = False - self._pending_url_elicitations = [] - - def _client_implementation(self) -> Implementation: - from importlib.metadata import version - - if self.server_config and self.server_config.implementation: - return self.server_config.implementation - fast_agent_version = version("fast-agent-mcp") or "dev" - return Implementation(name="fast-agent-mcp", version=fast_agent_version) - - def _make_list_roots_callback(self) -> ListRootsFnT | None: - if self.server_config and self.server_config.roots: - return cast("ListRootsFnT", list_roots) - return None - - def _make_sampling_callback(self) -> SamplingFnT | None: - if ( - self.server_config and self.server_config.sampling - ) or self._should_enable_auto_sampling(): - return cast("SamplingFnT", sample) - return None - - @staticmethod - def _make_sampling_capabilities( - sampling_cb: SamplingFnT | None, - ) -> SamplingCapability | None: - if sampling_cb is None: - return None - return SamplingCapability(tools=SamplingToolsCapability()) - - def _resolve_elicitation_handler(self, custom_elicitation_handler: Any) -> Any | None: - if custom_elicitation_handler is not None: - return custom_elicitation_handler - - elicitation_handler = self._resolve_configured_elicitation_handler() - if elicitation_handler is not None or self.server_config: - return elicitation_handler - - from fast_agent.mcp.elicitation_handlers import forms_elicitation_handler - - return forms_elicitation_handler - - def _resolve_configured_elicitation_handler(self) -> Any | None: - try: - from fast_agent.agents.agent_types import AgentConfig - from fast_agent.context import get_current_context - from fast_agent.mcp.elicitation_factory import resolve_elicitation_handler - - context = get_current_context() - if not context or not context.config: - return None - agent_config = AgentConfig( - name=self.agent_name or "unknown", - model=self.agent_model or "unknown", - elicitation_handler=None, - ) - return resolve_elicitation_handler(agent_config, context.config, self.server_config) - except Exception: - return None - - def _resolve_effective_elicitation_mode(self, elicitation_handler: Any | None) -> str: - if self.server_config and self.server_config.elicitation is not None: - return self.server_config.elicitation.mode or "forms" - if elicitation_handler is None: - return "none" - return self._global_elicitation_mode() or "forms" - - @staticmethod - def _global_elicitation_mode() -> str | None: - from fast_agent.context import get_current_context - from fast_agent.mcp.elicitation_factory import resolve_global_elicitation_mode - - context = get_current_context() - if not context or not context.config: - return None - return resolve_global_elicitation_mode(context.config) - - @staticmethod - def _discard_managed_client_kwargs(kwargs: dict[str, Any]) -> None: - for key in ( - "list_roots_callback", - "sampling_callback", - "sampling_capabilities", - "client_info", - "elicitation_callback", - "message_handler", - ): - kwargs.pop(key, None) - - def _should_enable_auto_sampling(self) -> bool: - """Check if auto_sampling is enabled at the application level.""" - try: - from fast_agent.context import get_current_context - - return resolve_auto_sampling_enabled(get_current_context()) - except Exception: - return True - - async def send_request( - self, - request: ClientRequest | Request[Any, Any], - result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT], - request_read_timeout_seconds: float | None = None, - metadata: ClientMessageMetadata | None = None, - progress_callback: ProgressFnT | None = None, - ) -> ReceiveResultT: - logger.debug("send_request: request=", data=request.model_dump()) - request_id = None - is_ping_request = self._is_ping_request(request) - request_method = request.method - - self._trace_request_progress( - "outbound-request", - request_method=request_method, - request_id=request_id, - progress_callback=progress_callback, - extra=f" progress_token={request_id!r}", - ) - self._register_ping_request(is_ping_request, request_id) - try: - result = await super().send_request( - # NOTE: request must be positional due to an upstream bug in - # opentelemetry-instrumentation-mcp (seen in 0.52.1) where the - # wrapper expects args[0] and can return None when request is - # only provided via kwargs. - # TODO: revert to keyword argument once upstream handles kwargs. - request, - result_type=result_type, - request_read_timeout_seconds=request_read_timeout_seconds, - metadata=metadata, - progress_callback=progress_callback, - ) - self._handle_successful_request( - result, - is_ping_request=is_ping_request, - request_id=request_id, - request_method=request_method, - progress_callback=progress_callback, - ) - return result - except Exception as e: - self._handle_failed_request( - e, - is_ping_request=is_ping_request, - request_id=request_id, - request_method=request_method, - progress_callback=progress_callback, - ) - raise - - def _trace_request_progress( - self, - event: str, - *, - request_method: str, - request_id: Any, - progress_callback: ProgressFnT | None, - extra: str = "", - ) -> None: - if progress_callback is None or request_id is None: - return - _progress_trace( - f"{event} " - f"server={self.session_server_name or 'unknown'} " - f"method={request_method} " - f"request_id={request_id!r}" - f"{extra}" - ) - - def _register_ping_request(self, is_ping_request: bool, request_id: Any) -> None: - if is_ping_request and request_id is not None and self._transport_metrics is not None: - self._transport_metrics.register_ping_request(request_id) - - def _discard_ping_request(self, is_ping_request: bool, request_id: Any) -> None: - if is_ping_request and request_id is not None and self._transport_metrics is not None: - self._transport_metrics.discard_ping_request(request_id) - - def _handle_successful_request( - self, - result: ReceiveResultT, - *, - is_ping_request: bool, - request_id: Any, - request_method: str, - progress_callback: ProgressFnT | None, - ) -> None: - logger.debug( - "send_request: response=", - data=result.model_dump() if result is not None else "no response returned", - ) - - self._trace_request_progress( - "request-complete", - request_method=request_method, - request_id=request_id, - progress_callback=progress_callback, - ) - - self._attach_transport_channel(request_id, result) - self._attach_url_elicitation_payload_from_result( - result, - request_method=request_method, - ) - self._attach_pending_url_elicitation_payload_for_request( - result, - request_method=request_method, - ) - self._discard_ping_request(is_ping_request, request_id) - self._offline_notified = False - - def _handle_failed_request( - self, - exc: Exception, - *, - is_ping_request: bool, - request_id: Any, - request_method: str, - progress_callback: ProgressFnT | None, - ) -> None: - from anyio import ClosedResourceError - - from fast_agent.core.exceptions import ServerSessionTerminatedError - - self._discard_pending_url_elicitation_payload() - self._trace_request_progress( - "request-error", - request_method=request_method, - request_id=request_id, - progress_callback=progress_callback, - extra=f" error={type(exc).__name__}: {exc}", - ) - self._discard_ping_request(is_ping_request, request_id) - - if self._is_session_terminated_error(exc): - raise ServerSessionTerminatedError( - server_name=self.session_server_name or "unknown", - details="Server returned 404 - session may have expired due to server restart", - ) from exc - - if self._is_url_elicitation_required_error(exc): - self._attach_url_elicitation_required_payload(exc, request_method) - - if isinstance(exc, ClosedResourceError): - self._raise_connection_offline(exc) - - logger.error(f"send_request failed: {exc!s}") - - def _raise_connection_offline(self, exc: Exception) -> None: - if not self._offline_notified: - from fast_agent.ui import console - - console.console.print( - f"[dim red]MCP server {self.session_server_name} offline[/dim red]" - ) - self._offline_notified = True - raise ConnectionError(f"MCP server {self.session_server_name} offline") from exc - - @staticmethod - def _is_ping_request(request: Any) -> bool: - method = request.method - if not isinstance(method, str): - return False - method_lower = strip_casefold(method) - return method_lower == "ping" or method_lower.endswith(("/ping", ".ping")) - - def _is_session_terminated_error(self, exc: Exception) -> bool: - """Check if exception is a session terminated error (code 32600 from 404).""" - from mcp.shared.exceptions import MCPError - - from fast_agent.core.exceptions import ServerSessionTerminatedError - - return ( - isinstance(exc, MCPError) - and exc.code == ServerSessionTerminatedError.SESSION_TERMINATED_CODE - ) - - def _is_url_elicitation_required_error(self, exc: Exception) -> bool: - """Check if exception is URL elicitation required error (-32042).""" - from mcp.shared.exceptions import MCPError - - return isinstance(exc, MCPError) and exc.code == URL_ELICITATION_REQUIRED - - def _attach_url_elicitation_required_payload(self, exc: Exception, request_method: str) -> None: - """Attach parsed URL elicitation data to exception for deferred display.""" - from mcp.shared.exceptions import MCPError - - if not isinstance(exc, MCPError): - return - - server_name = self.session_server_name or "unknown" - payload = build_url_elicitation_required_display_payload( - exc.data, - server_name=server_name, - request_method=request_method, - ) - set_url_elicitation_required_payload(exc, payload) - - def queue_url_elicitation_for_active_request( - self, - *, - message: str, - url: str, - elicitation_id: str | None, - ) -> bool: - """Queue URL elicitation for the next successful request result.""" - self._pending_url_elicitations.append( - URLElicitationDisplayItem( - message=message, - url=url, - elicitation_id=elicitation_id or "", - ) - ) - return True - - def _attach_pending_url_elicitation_payload_for_request( - self, - result: object, - *, - request_method: str, - ) -> None: - payload = self._consume_pending_url_elicitation_payload(request_method=request_method) - if payload is None: - return - with suppress(Exception): - set_url_elicitation_required_payload(result, payload) - - def _consume_pending_url_elicitation_payload( - self, - *, - request_method: str, - ) -> URLElicitationRequiredDisplayPayload | None: - if not self._pending_url_elicitations: - return None - - items = self._pending_url_elicitations - self._pending_url_elicitations = [] - return URLElicitationRequiredDisplayPayload( - server_name=self.session_server_name or "unknown", - request_method=request_method, - elicitations=items, - issues=[], - ) - - def _discard_pending_url_elicitation_payload(self) -> None: - self._pending_url_elicitations = [] - - def _attach_url_elicitation_payload_from_result( - self, - result: object, - *, - request_method: str, - ) -> None: - if not isinstance(result, CallToolResult) or not result.is_error or not result.content: - return - - first_block = result.content[0] - text = getattr(first_block, "text", None) - if not isinstance(text, str) or _URL_ELICITATION_RESULT_PREFIX not in text: - return - - _, _, raw_payload = text.partition(_URL_ELICITATION_RESULT_PREFIX) - try: - payload_data = json.loads(raw_payload.strip()) - except json.JSONDecodeError: - return - if not isinstance(payload_data, dict): - return - - payload = build_url_elicitation_required_display_payload( - payload_data, - server_name=self.session_server_name or "unknown", - request_method=request_method, - ) - with suppress(Exception): - set_url_elicitation_required_payload(result, payload) - - @staticmethod - def get_url_elicitation_required_payload( - exc: object, - ) -> URLElicitationRequiredDisplayPayload | None: - """Return deferred URL elicitation display payload when present.""" - return url_elicitation_required_payload(exc) - - def _attach_transport_channel(self, request_id, result) -> None: - if self._transport_metrics is None or request_id is None or result is None: - return - channel = self._transport_metrics.consume_response_channel(request_id) - if not channel: - return - with suppress(Exception): - update_tool_result_display_metadata(result, {"transport_channel": channel}) - - async def _handle_message(self, message: object) -> None: - if isinstance(message, ToolListChangedNotification): - if self._tool_list_changed_callback and self.session_server_name: - asyncio.create_task( - self._handle_tool_list_change_callback(self.session_server_name) - ) - if self._aggregator and not isinstance(message, ProgressNotification | Exception): - asyncio.create_task(self._handle_server_notification(message)) - - async def _handle_server_notification(self, notification: object) -> None: - """Forward server notifications to the registered callback.""" - _cb = ( - getattr(self._aggregator, "server_notification_callback", None) - if self._aggregator - else None - ) - if not _cb: - return - try: - await _cb( - self.session_server_name or "unknown", - notification, - ) - except Exception as e: - logger.warning( - f"Error in server notification callback for '{self.session_server_name}': {e}" - ) - - async def handle_subscription_event(self, event: ServerEvent) -> None: - if self._aggregator is None or self.session_server_name is None: - return - if isinstance(event, ToolsListChanged): - await self._aggregator._handle_tool_list_changed(self.session_server_name) - elif isinstance(event, PromptsListChanged): - await self._aggregator._fetch_and_cache_prompts(self.session_server_name) - elif isinstance(event, ResourcesListChanged): - await self._aggregator._refresh_server_resources(self.session_server_name) - - async def _dispatch_input( - self, - key: str, - request: InputRequest, - ) -> InputResponse | ErrorData: - from mcp.client.session import ClientRequestContext - - context = ClientRequestContext( - session=self, - request_id=key, - meta=request.params.meta if request.params else None, - ) - return await self.dispatch_input_request(context, request) - - async def _handle_tool_list_change_callback(self, server_name: str) -> None: - """ - Helper method to handle tool list change callback in a separate task - to prevent blocking the notification handler - """ - try: - await self._tool_list_changed_callback(server_name) - except Exception as e: - logger.error(f"Error in tool list changed callback: {e}") - - async def call_tool_complete( - self, - name: str, - arguments: dict[str, Any] | None = None, - read_timeout_seconds: float | None = None, - progress_callback: ProgressFnT | None = None, - *, - meta: dict[str, Any] | None = None, - ) -> CallToolResult: - """Call a tool with optional metadata and progress callback support. - - Always uses our overridden send_request to ensure session terminated errors - are properly detected and converted to ServerSessionTerminatedError. - """ - first = await super().call_tool( - name, - arguments, - read_timeout_seconds, - progress_callback, - meta=cast("RequestParamsMeta | None", meta), - allow_input_required=True, - ) - if not isinstance(first, InputRequiredResult): - return first - - async def retry( - responses: InputResponses | None, - request_state: str | None, - ) -> CallToolResult | InputRequiredResult: - return await super(MCPAgentClientSession, self).call_tool( - name, - arguments, - read_timeout_seconds, - progress_callback, - input_responses=responses, - request_state=request_state, - meta=cast("RequestParamsMeta | None", meta), - allow_input_required=True, - ) - - return await run_input_required_driver( - first, - dispatch=self._dispatch_input, - retry=retry, - ) - - async def ping(self, read_timeout_seconds: float | None = None) -> EmptyResult: - """Send a ping request to check server liveness.""" - del read_timeout_seconds - return await self.send_ping() - - async def read_resource_complete( - self, - uri: AnyUrl | str, - *, - meta: RequestParamsMeta | None = None, - ) -> ReadResourceResult: - """Read a resource with optional metadata support. - - Always uses our overridden send_request to ensure session terminated errors - are properly detected and converted to ServerSessionTerminatedError. - """ - first = await super().read_resource(str(uri), meta=meta, allow_input_required=True) - if not isinstance(first, InputRequiredResult): - return first - - async def retry( - responses: InputResponses | None, - request_state: str | None, - ) -> ReadResourceResult | InputRequiredResult: - return await super(MCPAgentClientSession, self).read_resource( - str(uri), - input_responses=responses, - request_state=request_state, - meta=meta, - allow_input_required=True, - ) - - return await run_input_required_driver( - first, - dispatch=self._dispatch_input, - retry=retry, - ) - - async def read_directory( - self, - uri: AnyUrl | str, - *, - cursor: str | None = None, - ) -> ListResourcesResult: - """List the direct children of a directory resource (SEP-2640). - - Sends ``resources/directory/read`` and reuses the ``resources/list`` - result shape (``resources`` + ``nextCursor``). Callers MUST only invoke - this against servers that declared ``directoryRead``. The method is not in - the closed ``ClientRequest`` union, so the bare request goes through our - ``send_request`` override; wrapping it would emit union serializer warnings. - """ - params = DirectoryReadRequestParams(uri=str(uri), cursor=cursor) - request = DirectoryReadRequest(method="resources/directory/read", params=params) - return await self.send_request(request, ListResourcesResult) - - async def get_prompt_complete( - self, - name: str, - arguments: dict[str, str] | None = None, - *, - meta: RequestParamsMeta | None = None, - ) -> GetPromptResult: - """Get a prompt with optional metadata support. - - Always uses our overridden send_request to ensure session terminated errors - are properly detected and converted to ServerSessionTerminatedError. - """ - first = await super().get_prompt( - name, - arguments, - meta=meta, - allow_input_required=True, - ) - if not isinstance(first, InputRequiredResult): - return first - - async def retry( - responses: InputResponses | None, - request_state: str | None, - ) -> GetPromptResult | InputRequiredResult: - return await super(MCPAgentClientSession, self).get_prompt( - name, - arguments, - input_responses=responses, - request_state=request_state, - meta=meta, - allow_input_required=True, - ) - - return await run_input_required_driver( - first, - dispatch=self._dispatch_input, - retry=retry, - ) diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 8fa98be00..5d95cca80 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -10,14 +10,11 @@ Any, Generic, Literal, - Protocol, TypeVar, cast, - runtime_checkable, ) from mcp import GetPromptResult, ReadResourceResult -from mcp.client.session import ClientSession from mcp.shared.exceptions import MCPError from mcp.shared.session import ProgressFnT from mcp_types import ( @@ -51,11 +48,12 @@ ) from fast_agent.event_progress import ProgressAction from fast_agent.mcp.auth.context import request_bearer_token +from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime +from fast_agent.mcp.client_connection import MCPClientConnection from fast_agent.mcp.common import SEP, create_namespaced_name, is_namespaced_name from fast_agent.mcp.gen_client import gen_client from fast_agent.mcp.helpers.content_helpers import get_text -from fast_agent.mcp.interfaces import CompletingClientSession, ServerRegistryProtocol -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession +from fast_agent.mcp.interfaces import ServerRegistryProtocol from fast_agent.mcp.mcp_connection_manager import ( MCPConnectionManager, ServerConnection, @@ -78,7 +76,10 @@ ToolPermissionHandler, ToolPermissionResult, ) -from fast_agent.mcp.tool_result_metadata import set_url_elicitation_required_payload +from fast_agent.mcp.tool_result_metadata import ( + set_url_elicitation_required_payload, + url_elicitation_required_payload, +) from fast_agent.mcp.transport_tracking import TransportSnapshot from fast_agent.skills.mcp_registry import ( McpSkillRegistry, @@ -98,6 +99,8 @@ logger = get_logger(__name__) # This will be replaced per-instance when agent_name is available +type MCPOperationClient = MCPClientConnection + def _display_tool_id(tool_id: str | None) -> str: return format_tool_call_id(tool_id) or "unknown" @@ -162,22 +165,6 @@ async def get_resource( METHOD_NOT_FOUND_MESSAGE = "method not found" -@runtime_checkable -class ElicitationModeCapable(Protocol): - effective_elicitation_mode: str | None - - -@runtime_checkable -class ClientInfoLike(Protocol): - name: str | None - version: str | None - - -@runtime_checkable -class SessionClientInfoCapable(Protocol): - client_info: ClientInfoLike | None - - def _is_capability_probe_error(exc: Exception) -> bool: """Return True when exc indicates a server does not support a probed method.""" if isinstance(exc, NotImplementedError): @@ -313,7 +300,7 @@ class MCPAggregator(ContextDependent): """Whether the aggregator has been initialized with tools and resources from all servers.""" connection_persistence: bool = False - """Whether to maintain a persistent connection to the server.""" + """Whether to retain an attached local client runtime for the server.""" server_names: list[str] """A list of server names to connect to.""" @@ -328,7 +315,7 @@ async def __aenter__(self): if self.initialized: return self - # Keep a connection manager to manage persistent connections for this aggregator + # Keep a runtime manager for attached clients owned by this aggregator. if self.connection_persistence: context = self._require_context() # Try to get existing connection manager from context @@ -368,7 +355,7 @@ def __init__( ) -> None: """ :param server_names: A list of server names to connect to. - :param connection_persistence: Whether to maintain persistent connections to servers (default: True). + :param connection_persistence: Whether to retain attached client runtimes (default: True). :param config: Optional agent config containing elicitation_handler and other settings. :param tool_handler: Optional handler for tool execution lifecycle events (e.g., for ACP notifications). :param permission_handler: Optional handler for tool permission checks (e.g., for ACP permissions). @@ -385,7 +372,7 @@ def __init__( self._supplemental_attached_server_names: list[str] = [] self.connection_persistence = connection_persistence self.agent_name = name - self.config = config # Store the config for access in session factory + self.config = config # Agent-specific callback configuration. self._persistent_connection_manager: MCPConnectionManager | None = None self._owns_connection_manager = False @@ -441,7 +428,7 @@ def __init__( self._skybridge_configs: dict[str, SkybridgeServerConfig] = {} self._mcp_skill_registries: dict[str, McpSkillRegistry] = {} - # Cache for server capabilities in non-persistent mode + # Cache for capabilities discovered by on-demand clients. self._capabilities_cache: dict[str, ServerCapabilities] = {} self._capabilities_cache_lock = Lock() @@ -486,7 +473,7 @@ def _should_use_request_scoped_connection(self, server_name: str) -> bool: def _require_connection_manager(self) -> MCPConnectionManager: if self._persistent_connection_manager is None: - raise RuntimeError("Persistent connection manager is not initialized") + raise RuntimeError("MCP runtime manager is not initialized") return self._persistent_connection_manager def _create_progress_callback( @@ -539,7 +526,7 @@ async def progress_callback( async def close(self) -> None: """ - Close all persistent connections when the aggregator is deleted. + Close all attached MCP client runtimes when the aggregator is deleted. """ if self.connection_persistence and self._persistent_connection_manager: try: @@ -548,7 +535,7 @@ async def close(self) -> None: self.context is not None and self.context._connection_manager == self._persistent_connection_manager ): - logger.info("Shutting down all persistent connections...") + logger.info("Shutting down attached MCP client runtimes...") await self._persistent_connection_manager.disconnect_all() await self._persistent_connection_manager.__aexit__(None, None, None) self.context._connection_manager = None @@ -586,61 +573,36 @@ async def create( await instance.__aexit__(None, None, None) raise - def _create_session_factory(self, server_name: str): - """ - Create a session factory function for the given server. - This centralizes the logic for creating MCPAgentClientSession instances. - - Args: - server_name: The name of the server to create a session for - - Returns: - A factory function that creates MCPAgentClientSession instances - """ - - def session_factory(read_stream, write_stream, read_timeout, **kwargs): - # Get agent's model and name from config if available - agent_model: str | None = None - agent_name: str | None = None - elicitation_handler = None - api_key: str | None = None - - # Access config directly if it was passed from BaseAgent - if self.config: - resolved_model = resolve_model_spec( - self.context, - model=self.config.model, - cli_model=get_context_cli_model_override(self.context), - hardcoded_default=HARDCODED_DEFAULT_MODEL, - ) - agent_model = resolved_model.model - if resolved_model.source: - logger.info( - f"Resolved MCP agent model '{agent_model}' via {resolved_model.source}", - model=agent_model, - source=resolved_model.source, - ) - agent_name = self.config.name - elicitation_handler = self.config.elicitation_handler - api_key = self.config.api_key - - session = MCPAgentClientSession( - read_stream, - write_stream, - read_timeout, - server_name=server_name, - agent_model=agent_model, - agent_name=agent_name, - api_key=api_key, - elicitation_handler=elicitation_handler, - tool_list_changed_callback=self._handle_tool_list_changed, - aggregator=self, - **kwargs, # Pass through any additional kwargs like server_config + def _create_callback_runtime(self, server_name: str) -> MCPClientCallbackRuntime: + """Build callbacks and agent context for an SDK high-level client.""" + agent_model: str | None = None + agent_name: str | None = None + elicitation_handler = None + api_key: str | None = None + + if self.config: + resolved_model = resolve_model_spec( + self.context, + model=self.config.model, + cli_model=get_context_cli_model_override(self.context), + hardcoded_default=HARDCODED_DEFAULT_MODEL, ) + agent_model = resolved_model.model + agent_name = self.config.name + elicitation_handler = self.config.elicitation_handler + api_key = self.config.api_key - return session - - return session_factory + return MCPClientCallbackRuntime( + server_name=server_name, + server_config=self._require_server_registry().get_server_config(server_name), + agent_model=agent_model, + agent_name=agent_name, + api_key=api_key, + custom_elicitation_handler=elicitation_handler, + aggregator=self, + context=self.context, + tool_list_changed_callback=self._handle_tool_list_changed, + ) async def load_servers(self, *, force_connect: bool = False) -> None: """ @@ -870,7 +832,7 @@ async def _connect_persistent_server( attach_options: MCPAttachOptions, ) -> None: logger.info( - f"Creating persistent connection to server: {server_name}", + f"Creating attached MCP client runtime for server: {server_name}", data={ "progress_action": ProgressAction.CONNECTING, "server_name": server_name, @@ -882,7 +844,7 @@ async def _connect_persistent_server( connect = manager.reconnect_server if attach_options.force_reconnect else manager.get_server await connect( server_name, - client_session_factory=self._create_session_factory(server_name), + callback_runtime=self._create_callback_runtime(server_name), startup_timeout_seconds=attach_options.startup_timeout_seconds, trigger_oauth=attach_options.trigger_oauth, oauth_event_handler=attach_options.oauth_event_handler, @@ -1330,6 +1292,7 @@ async def get_capabilities(self, server_name: str) -> ServerCapabilities | None: server_registry = self._require_server_registry() async with server_registry.initialize_server( server_name=server_name, + callback_runtime=self._create_callback_runtime(server_name), ) as _session: capabilities = server_registry.get_server_capabilities(server_name) @@ -1345,7 +1308,7 @@ async def get_capabilities(self, server_name: str) -> ServerCapabilities | None: manager = self._require_connection_manager() server_conn = await manager.get_server( server_name, - client_session_factory=self._create_session_factory(server_name), + callback_runtime=self._create_callback_runtime(server_name), ) return server_conn.server_capabilities except Exception as e: @@ -1695,8 +1658,9 @@ def _apply_connection_status( status.server_capabilities = server_conn.server_capabilities status.mcp_skills_enabled = server_supports_mcp_skills(server_conn.server_capabilities) - status.client_capabilities = server_conn.client_capabilities - self._apply_client_info_status(status, server_conn.session) + client_info = server_conn._callback_runtime.client_info + status.client_info_name = client_info.name + status.client_info_version = client_info.version if server_conn._initialized_event.is_set(): status.is_connected = server_conn.is_healthy() @@ -1714,19 +1678,6 @@ def _apply_connection_status( self._apply_session_status(status, server_conn) self._apply_transport_status(status, server_conn) - @staticmethod - def _apply_client_info_status( - status: ServerStatus, - session: ClientSession | None, - ) -> None: - if not isinstance(session, SessionClientInfoCapable): - return - - client_info = session.client_info - if client_info: - status.client_info_name = client_info.name - status.client_info_version = client_info.version - @staticmethod def _apply_ping_status(status: ServerStatus, server_conn: ServerConnection) -> None: server_cfg = server_conn.server_config @@ -1744,21 +1695,8 @@ def _apply_session_status( status: ServerStatus, server_conn: ServerConnection, ) -> None: - session = server_conn.session - if isinstance(session, ElicitationModeCapable): - status.elicitation_mode = session.effective_elicitation_mode - - # Mcp-Session-Id from the transport, when the server assigns one. - status.session_id = server_conn.session_id or self._session_id_from_callback(server_conn) - - @staticmethod - def _session_id_from_callback(server_conn: ServerConnection) -> str | None: - if not server_conn._get_session_id_cb: - return None - try: - return server_conn._get_session_id_cb() - except Exception: - return None + status.elicitation_mode = server_conn._callback_runtime.effective_elicitation_mode + status.session_id = server_conn.session_id def _apply_transport_status( self, @@ -1838,7 +1776,7 @@ def _apply_config_status( if status.implementation_name is None and server_cfg.implementation is not None: status.implementation_name = server_cfg.implementation.name status.implementation_version = server_cfg.implementation.version - self._apply_config_session_id(status, server_cfg, server_conn) + self._apply_config_session_id(status, server_cfg) status.sampling_mode = ( "configured" if server_cfg.sampling is not None else self._auto_sampling_mode() ) @@ -1847,14 +1785,11 @@ def _apply_config_session_id( self, status: ServerStatus, server_cfg: MCPServerSettings, - server_conn: ServerConnection | None, ) -> None: if status.session_id is not None: return if server_cfg.transport == "stdio": status.session_id = "local" - elif server_conn is not None: - status.session_id = self._session_id_from_callback(server_conn) def _auto_sampling_mode(self) -> Literal["auto", "off"]: auto_sampling = True @@ -1891,7 +1826,7 @@ async def _execute_on_server( server_name: Name of the server to execute the operation on operation_type: Type of operation (for logging) e.g., "tool", "prompt" operation_name: Name of the specific operation being called (for logging) - method_name: Name of the method to call on the client session + method_name: Name of the high-level client method to call method_args: Arguments to pass to the method error_factory: Function to create an error return value if the operation fails progress_callback: Optional progress callback for operations that support it @@ -1900,7 +1835,7 @@ async def _execute_on_server( Result from the operation or an error result """ - async def try_execute(client: ClientSession) -> R: + async def try_execute(client: MCPOperationClient) -> R: return await self._execute_session_method( client, server_name=server_name, @@ -1951,7 +1886,7 @@ async def try_execute(client: ClientSession) -> R: async def _execute_session_method( self, - client: ClientSession, + client: MCPOperationClient, *, server_name: str, operation_name: str, @@ -1962,18 +1897,16 @@ async def _execute_session_method( ) -> R: try: if method_name in {"call_tool", "read_resource", "get_prompt"}: - if not isinstance(client, CompletingClientSession): - raise TypeError("MCP session factory must provide completed MRTR operations") kwargs = self._server_method_kwargs(method_name, method_args) if method_name == "call_tool": - result = await client.call_tool_complete( + result = await client.call_tool( progress_callback=progress_callback, **kwargs, ) elif method_name == "read_resource": - result = await client.read_resource_complete(**kwargs) + result = await client.read_resource(**kwargs) else: - result = await client.get_prompt_complete(**kwargs) + result = await client.get_prompt(**kwargs) return cast("R", result) method = getattr(client, method_name) @@ -2020,7 +1953,7 @@ def _handle_session_method_error( raise exc error_result = error_factory(error_msg) - payload = MCPAgentClientSession.get_url_elicitation_required_payload(exc) + payload = url_elicitation_required_payload(exc) if payload is not None: with suppress(Exception): set_url_elicitation_required_payload(error_result, payload) @@ -2029,7 +1962,7 @@ def _handle_session_method_error( async def _execute_initial_server_operation( self, server_name: str, - try_execute: Callable[[ClientSession], Awaitable[R]], + try_execute: Callable[[MCPOperationClient], Awaitable[R]], ) -> R: if self.connection_persistence and not self._should_use_request_scoped_connection( server_name @@ -2040,22 +1973,22 @@ async def _execute_initial_server_operation( async def _execute_persistent_server_operation( self, server_name: str, - try_execute: Callable[[ClientSession], Awaitable[R]], + try_execute: Callable[[MCPOperationClient], Awaitable[R]], ) -> R: manager = self._require_connection_manager() server_connection = await manager.get_server( server_name, - client_session_factory=self._create_session_factory(server_name), + callback_runtime=self._create_callback_runtime(server_name), ) - session = server_connection.session - if session is None: - raise RuntimeError(f"Server session not initialized for '{server_name}'") - return await try_execute(session) + client = server_connection.client + if client is None: + raise RuntimeError(f"MCP client runtime not initialized for '{server_name}'") + return await try_execute(client) async def _execute_temporary_server_operation( self, server_name: str, - try_execute: Callable[[ClientSession], Awaitable[R]], + try_execute: Callable[[MCPOperationClient], Awaitable[R]], ) -> R: logger.debug( f"Creating temporary connection to server: {server_name}", @@ -2066,7 +1999,11 @@ async def _execute_temporary_server_operation( }, ) server_registry = self._require_server_registry() - async with gen_client(server_name, server_registry=server_registry) as client: + async with gen_client( + server_name, + server_registry=server_registry, + callback_runtime=self._create_callback_runtime(server_name), + ) as client: result = await try_execute(client) logger.debug( f"Closing temporary connection to server: {server_name}", @@ -2125,18 +2062,19 @@ async def _handle_auth_challenge( manager = self._require_connection_manager() server_connection = await manager.reconnect_server( server_name, - client_session_factory=self._create_session_factory(server_name), + callback_runtime=self._create_callback_runtime(server_name), trigger_oauth=True, ) - session = server_connection.session - if session is None: - raise RuntimeError(f"Server session not initialized for '{server_name}'") - result = await try_execute(session) + client = server_connection.client + if client is None: + raise RuntimeError(f"MCP client runtime not initialized for '{server_name}'") + result = await try_execute(client) else: server_registry = self._require_server_registry() async with gen_client( server_name, server_registry=server_registry, + callback_runtime=self._create_callback_runtime(server_name), trigger_oauth=True, ) as client: result = await try_execute(client) @@ -2169,16 +2107,20 @@ async def _handle_connection_error( manager = self._require_connection_manager() server_connection = await manager.reconnect_server( server_name, - client_session_factory=self._create_session_factory(server_name), + callback_runtime=self._create_callback_runtime(server_name), ) - session = server_connection.session - if session is None: - raise RuntimeError(f"Server session not initialized for '{server_name}'") - result = await try_execute(session) + client = server_connection.client + if client is None: + raise RuntimeError(f"MCP client runtime not initialized for '{server_name}'") + result = await try_execute(client) else: - # For non-persistent connections, just try again + # For on-demand clients, create a fresh runtime. server_registry = self._require_server_registry() - async with gen_client(server_name, server_registry=server_registry) as client: + async with gen_client( + server_name, + server_registry=server_registry, + callback_runtime=self._create_callback_runtime(server_name), + ) as client: result = await try_execute(client) # Success! @@ -2250,16 +2192,20 @@ async def _handle_session_terminated( manager = self._require_connection_manager() server_connection = await manager.reconnect_server( server_name, - client_session_factory=self._create_session_factory(server_name), + callback_runtime=self._create_callback_runtime(server_name), ) - session = server_connection.session - if session is None: - raise RuntimeError(f"Server session not initialized for '{server_name}'") - result = await try_execute(session) + client = server_connection.client + if client is None: + raise RuntimeError(f"MCP client runtime not initialized for '{server_name}'") + result = await try_execute(client) else: - # For non-persistent connections, just try again + # For on-demand clients, create a fresh runtime. server_registry = self._require_server_registry() - async with gen_client(server_name, server_registry=server_registry) as client: + async with gen_client( + server_name, + server_registry=server_registry, + callback_runtime=self._create_callback_runtime(server_name), + ) as client: result = await try_execute(client) # Success! Record the reconnection diff --git a/src/fast_agent/mcp/mcp_connection_manager.py b/src/fast_agent/mcp/mcp_connection_manager.py index 8d6716d1e..01124e422 100644 --- a/src/fast_agent/mcp/mcp_connection_manager.py +++ b/src/fast_agent/mcp/mcp_connection_manager.py @@ -2,12 +2,13 @@ Manages the lifecycle of multiple MCP server connections. """ +from __future__ import annotations + import asyncio import threading import time import traceback from collections import deque -from collections.abc import Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress from dataclasses import dataclass from datetime import datetime, timezone @@ -17,30 +18,25 @@ import httpx2 from anyio import CancelScope, Event, Lock, create_task_group -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx2 import HTTPStatusError -from mcp import ClientSession -from mcp.client._probe import negotiate_auto from mcp.client.sse import sse_client from mcp.client.stdio import ( StdioServerParameters, get_default_environment, ) from mcp.client.streamable_http import streamable_http_client -from mcp.client.subscriptions import SubscriptionLost, listen +from mcp.client.subscriptions import SubscriptionLost from mcp.shared.exceptions import MCPError -from mcp_types import Implementation, JSONRPCMessage, ServerCapabilities -from fast_agent.config import MCPServerSettings from fast_agent.context_dependent import ContextDependent from fast_agent.core.exceptions import ServerInitializationError from fast_agent.core.logging.logger import get_logger from fast_agent.event_progress import ProgressAction from fast_agent.home import build_child_environment +from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime +from fast_agent.mcp.client_connection import MCPClientConnection, MCPTransportAdapter from fast_agent.mcp.hf_auth import add_forwarded_hf_auth_header -from fast_agent.mcp.interfaces import ClientSessionFactory from fast_agent.mcp.logger_textio import get_stderr_handler -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from fast_agent.mcp.oauth_client import ( OAuthEvent, OAuthEventHandler, @@ -55,8 +51,13 @@ from fast_agent.utils.transports import is_mcp_client_transport, uses_mcp_remote_transport if TYPE_CHECKING: + from collections.abc import Callable + + from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.client.auth import OAuthClientProvider + from mcp_types import Implementation, JSONRPCMessage, ServerCapabilities + from fast_agent.config import MCPServerSettings from fast_agent.context import Context from fast_agent.mcp_server_registry import ServerRegistry @@ -66,14 +67,14 @@ @runtime_checkable -class PingableClientSession(Protocol): - """Client session capability used by the optional keepalive loop.""" +class PingableMCPClient(Protocol): + """High-level client capability used by the optional legacy keepalive loop.""" async def ping(self, read_timeout_seconds: float | None = None) -> object: ... -def _pingable_session(session: object | None) -> PingableClientSession | None: - return session if isinstance(session, PingableClientSession) else None +def _pingable_client(client: object | None) -> PingableMCPClient | None: + return client if isinstance(client, PingableMCPClient) else None @dataclass(frozen=True, slots=True) @@ -301,18 +302,18 @@ def create_transport_context( Create a transport context manager for the given server configuration. Handles stdio/sse/http transport creation and header preparation, but NOT - lifecycle management, task groups, or connection persistence. Used by - ServerRegistry.initialize_server() for non-persistent connections. + lifecycle management, task groups, or attached-runtime ownership. Used by + ServerRegistry.initialize_server() for on-demand clients. Note: OAuth event handlers (oauth_event_handler, oauth_abort_event) and transport_metrics (channel_hook) are intentionally omitted. This function creates short-lived probe connections where full lifecycle tracking is - unnecessary. The persistent path in MCPConnectionManager.launch_server() + unnecessary. The attached path in MCPConnectionManager.launch_server() uses its own transport_context_factory closure with those features. """ # Short-lived probe connections intentionally avoid speculative OAuth startup. # Plain local HTTP/SSE servers can hang if we begin an auth flow before the - # server has actually challenged the request; higher-level non-persistent + # server has actually challenged the request; higher-level on-demand # callers may still retry with OAuth enabled after a 401 challenge. oauth_mode = _resolve_oauth_mode(config, trigger_oauth=trigger_oauth) prepared_auth = _prepare_headers_and_auth( @@ -380,9 +381,7 @@ def create_transport_context( class ServerConnection: """ - Represents a long-lived MCP server connection, including: - - The ClientSession to the server - - The transport streams (via stdio/sse, etc.) + Represents an attached local MCP client runtime and its product status. """ def __init__( @@ -399,14 +398,14 @@ def __init__( ] ], ], - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, ) -> None: self.server_name = server_name self.server_config = server_config - self.session: ClientSession | None = None - self._client_session_factory = client_session_factory + self.client: MCPClientConnection | None = None + self._callback_runtime = callback_runtime self._transport_context_factory = transport_context_factory - # Signal that session is fully up and initialized + # Signal that the runtime is fully up and negotiated. self._initialized_event = Event() # Signal we want to shut down @@ -425,13 +424,11 @@ def __init__( self.supported_protocol_versions: tuple[str, ...] = () self.negotiation: str | None = None self.subscription_state: str | None = None - self.client_capabilities: dict | None = None self.server_instructions_available: bool = False self.server_instructions_enabled: bool = ( server_config.include_instructions if server_config else True ) self.session_id: str | None = None - self._get_session_id_cb: Callable[[], str | None] | None = None self.transport_metrics: TransportChannelMetrics | None = None self._ping_ok_count = 0 self._ping_fail_count = 0 @@ -444,13 +441,14 @@ def __init__( self._oauth_wait_started_at: float | None = None self._oauth_wait_accumulated_seconds = 0.0 self._oauth_callback_timed_out = False + self._auth_challenge_received = False self._oauth_abort_event = threading.Event() self._stdio_stderr_lines: deque[str] = deque(maxlen=STDIO_STDERR_BUFFER_LINES) self._lifecycle_cancel_scope: CancelScope | None = None def is_healthy(self) -> bool: """Check if the server connection is healthy and ready to use.""" - return self.session is not None and not self._error_occurred + return self.client is not None and not self._error_occurred def request_shutdown(self) -> None: """ @@ -471,24 +469,23 @@ async def wait_for_shutdown_request(self) -> None: """ await self._shutdown_event.wait() - async def initialize_session(self) -> None: + async def initialize_client(self) -> None: """ - Initializes the server connection and session. + Capture negotiated peer metadata from the entered SDK client. Must be called within an async context. """ - assert self.session, "Session must be created before initialization" - await negotiate_auto(self.session) - self.protocol_version = self.session.protocol_version - discover_result = self.session.discover_result + assert self.client, "Client must be entered before initialization" + self.protocol_version = self.client.protocol_version + discover_result = self.client.discover_result self.protocol_era = "modern" if discover_result is not None else "legacy" self.negotiation = "discover" if discover_result is not None else "initialize" self.supported_protocol_versions = ( tuple(discover_result.supported_versions) if discover_result is not None else () ) - self.server_capabilities = self.session.server_capabilities - self.server_implementation = self.session.server_info + self.server_capabilities = self.client.server_capabilities + self.server_implementation = self.client.server_info - raw_instructions = self.session.instructions + raw_instructions = self.client.instructions self.server_instructions_available = bool(raw_instructions) # Store instructions if provided by the server and enabled in config @@ -511,12 +508,12 @@ async def initialize_session(self) -> None: # If there's an init hook, run it - # Now the session is ready for use + # The runtime is ready for use. self._initialized_event.set() async def wait_for_initialized(self) -> None: """ - Wait until the session is fully initialized. + Wait until the client runtime is fully initialized. """ await self._initialized_event.wait() @@ -557,6 +554,11 @@ def record_stdio_stderr(self, line: str) -> None: def recent_stdio_stderr_lines(self) -> tuple[str, ...]: return tuple(self._stdio_stderr_lines) + async def capture_http_response(self, response: httpx2.Response) -> None: + """Record an OAuth challenge before the SDK normalizes the transport error.""" + if response.status_code == 401: + self._auth_challenge_received = True + def record_ping_event(self, state: str) -> None: self._ping_history.append((datetime.now(timezone.utc), state)) @@ -594,30 +596,6 @@ def build_ping_activity_buckets(self, bucket_seconds: int, bucket_count: int) -> return buckets - def create_session( - self, - read_stream: MemoryObjectReceiveStream, - send_stream: MemoryObjectSendStream, - ) -> ClientSession: - """ - Create a new session instance for this server connection. - """ - - read_timeout = self.server_config.read_timeout_seconds - - session = self._client_session_factory( - read_stream, - send_stream, - read_timeout, - server_config=self.server_config, - transport_metrics=self.transport_metrics, - ) - - self.session = session - self.client_capabilities = getattr(session, "client_capabilities", None) - - return session - async def _run_ping_loop(server_conn: ServerConnection) -> None: interval = server_conn.server_config.ping_interval_seconds @@ -634,9 +612,8 @@ async def _run_ping_loop(server_conn: ServerConnection) -> None: await asyncio.sleep(interval) if server_conn._shutdown_event.is_set(): break - session = server_conn.session - pingable_session = _pingable_session(session) - if pingable_session is None: + client = _pingable_client(server_conn.client) + if client is None: return try: from fast_agent.human_input.elicitation_state import elicitation_state @@ -646,7 +623,7 @@ async def _run_ping_loop(server_conn: ServerConnection) -> None: except Exception: pass try: - await pingable_session.ping(read_timeout_seconds=read_timeout) + await client.ping(read_timeout_seconds=read_timeout) missed = 0 server_conn._ping_ok_count += 1 server_conn._ping_consecutive_failures = 0 @@ -742,34 +719,25 @@ async def _run_server_lifecycle(server_conn: ServerConnection) -> None: """Run the server lifecycle inside the connection-owned cancellation scope.""" try: transport_context = server_conn._transport_context_factory() - + connection = MCPClientConnection( + MCPTransportAdapter(transport_context), + server_conn._callback_runtime, + read_timeout_seconds=server_conn.server_config.read_timeout_seconds, + ) + server_conn.client = connection try: - async with transport_context as (read_stream, write_stream, get_session_id_cb): - server_conn._get_session_id_cb = get_session_id_cb - _refresh_server_session_id(server_conn, get_session_id_cb) - - server_conn.create_session(read_stream, write_stream) - assert server_conn.session is not None - - try: - async with server_conn.session: - await server_conn.initialize_session() - _refresh_server_session_id(server_conn, get_session_id_cb) - await _wait_for_shutdown_with_optional_ping(server_conn) - except Exception as session_exit_exc: - if not _handle_shutdown_cleanup_error( - server_conn, - session_exit_exc, - cleanup_scope="session", - ): - raise - except Exception as transport_exit_exc: + async with connection: + await server_conn.initialize_client() + await _wait_for_shutdown_with_optional_ping(server_conn) + except Exception as client_exit_exc: if not _handle_shutdown_cleanup_error( server_conn, - transport_exit_exc, - cleanup_scope="transport", + client_exit_exc, + cleanup_scope="client", ): raise + finally: + server_conn.client = None except HTTPStatusError as http_exc: _record_http_lifecycle_error(server_conn, http_exc) @@ -784,20 +752,6 @@ async def _run_server_lifecycle(server_conn: ServerConnection) -> None: # No raise - allow graceful exit -def _refresh_server_session_id( - server_conn: ServerConnection, - get_session_id_cb: Callable[[], str | None] | None, -) -> None: - if get_session_id_cb is not None: - try: - server_conn.session_id = get_session_id_cb() or server_conn.session_id - except Exception: - logger.debug(f"{server_conn.server_name}: Unable to retrieve session id from transport") - return - if server_conn.server_config.transport == "stdio": - server_conn.session_id = "local" - - async def _wait_for_shutdown_with_optional_ping(server_conn: ServerConnection) -> None: subscription_task: asyncio.Task[None] | None = None if server_conn.protocol_era == "modern" and server_conn.server_config.transport == "http": @@ -824,23 +778,23 @@ async def _wait_for_shutdown_with_optional_ping(server_conn: ServerConnection) - async def _run_subscription_loop(server_conn: ServerConnection) -> None: - session = server_conn.session - if not isinstance(session, MCPAgentClientSession): + client = server_conn.client + if client is None: server_conn.subscription_state = "unsupported" return delay = 0.25 while not server_conn._shutdown_event.is_set(): try: - async with listen( - session, + subscription_context = client.listen( tools_list_changed=True, prompts_list_changed=True, resources_list_changed=True, - ) as subscription: + ) + async with subscription_context as subscription: server_conn.subscription_state = "open" delay = 0.25 async for event in subscription: - await session.handle_subscription_event(event) + await client.callbacks.handle_subscription_event(event) server_conn.subscription_state = "closed" except SubscriptionLost: server_conn.subscription_state = "error" @@ -1186,8 +1140,8 @@ async def handle_event(event: OAuthEvent) -> None: async def launch_server( self, server_name: str, - client_session_factory: ClientSessionFactory, *, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None = None, trigger_oauth: bool | None = None, oauth_event_handler: OAuthEventHandler | None = None, @@ -1228,7 +1182,7 @@ async def launch_server( allow_oauth_paste_fallback=allow_oauth_paste_fallback, transport_metrics=transport_metrics, ), - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, ) server_conn_holder.append(server_conn) @@ -1246,7 +1200,7 @@ async def launch_server( assert self._tg is not None self._tg.start_soon(_server_lifecycle_task, server_conn) - logger.info(f"{server_name}: Up and running with a persistent connection!") + logger.info(f"{server_name}: Attached MCP client runtime is ready") return server_conn async def _ensure_task_group(self, server_name: str) -> None: @@ -1457,6 +1411,7 @@ def _persistent_http_transport_context( auth=oauth_auth, timeout=self._http_timeout(config), follow_redirects=True, + event_hooks={"response": [server_conn.capture_http_response]}, ) return _managed_http_transport_context( http_client, @@ -1511,7 +1466,7 @@ async def _launch_and_wait_for_server( self, *, server_name: str, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None, trigger_oauth: bool | None, oauth_event_handler: OAuthEventHandler | None, @@ -1521,7 +1476,7 @@ async def _launch_and_wait_for_server( """Launch a server connection and wait for initialization to complete.""" server_conn = await self.launch_server( server_name=server_name, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, trigger_oauth=trigger_oauth, oauth_event_handler=oauth_event_handler, @@ -1578,7 +1533,7 @@ async def _retry_server_with_oauth( *, server_name: str, server_conn: ServerConnection, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None, oauth_event_handler: OAuthEventHandler | None, allow_oauth_paste_fallback: bool, @@ -1593,7 +1548,7 @@ async def _retry_server_with_oauth( await asyncio.sleep(0.1) return await self._launch_and_wait_for_server( server_name=server_name, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, trigger_oauth=True, oauth_event_handler=oauth_event_handler, @@ -1602,17 +1557,21 @@ async def _retry_server_with_oauth( ) def should_retry_server_with_oauth(self, server_name: str, error: object) -> bool: + server_conn = self.running_servers.get(server_name) return ( self._server_oauth_mode.get(server_name) == "auto" and not self._server_oauth_active.get(server_name, False) - and _is_http_auth_challenge_error(error) + and ( + bool(server_conn and server_conn._auth_challenge_received) + or _is_http_auth_challenge_error(error) + ) ) async def get_server( self, server_name: str, - client_session_factory: ClientSessionFactory, *, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None = None, trigger_oauth: bool | None = None, oauth_event_handler: OAuthEventHandler | None = None, @@ -1626,7 +1585,7 @@ async def get_server( server_conn = await self._launch_and_wait_for_server( server_name=server_name, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, trigger_oauth=trigger_oauth, oauth_event_handler=oauth_event_handler, @@ -1637,7 +1596,7 @@ async def get_server( return await self._healthy_or_retry_server( server_name=server_name, server_conn=server_conn, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, oauth_event_handler=oauth_event_handler, allow_oauth_paste_fallback=allow_oauth_paste_fallback, @@ -1662,7 +1621,7 @@ async def _healthy_or_retry_server( *, server_name: str, server_conn: ServerConnection, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None, oauth_event_handler: OAuthEventHandler | None, allow_oauth_paste_fallback: bool, @@ -1674,7 +1633,7 @@ async def _healthy_or_retry_server( retried_conn = await self._retry_server_with_oauth( server_name=server_name, server_conn=server_conn, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, oauth_event_handler=oauth_event_handler, allow_oauth_paste_fallback=allow_oauth_paste_fallback, @@ -1729,8 +1688,16 @@ def _server_initialization_error_text(error_msg: str | list[str]) -> str: async def get_server_capabilities(self, server_name: str) -> ServerCapabilities | None: """Get the capabilities of a specific server.""" + config = self.server_registry.get_server_config(server_name) + if config is None: + return None server_conn = await self.get_server( - server_name, client_session_factory=MCPAgentClientSession + server_name, + callback_runtime=MCPClientCallbackRuntime( + server_name=server_name, + server_config=config, + context=self.context, + ), ) return server_conn.server_capabilities if server_conn else None @@ -1738,7 +1705,7 @@ async def disconnect_server(self, server_name: str) -> None: """ Disconnect a specific server if it's running under this connection manager. """ - logger.info(f"{server_name}: Disconnecting persistent connection to server...") + logger.info(f"{server_name}: Detaching MCP client runtime...") async with self._lock: server_conn = self.running_servers.pop(server_name, None) @@ -1748,27 +1715,26 @@ async def disconnect_server(self, server_name: str) -> None: server_conn.request_shutdown() logger.info(f"{server_name}: Shutdown signal sent (lifecycle task will exit).") else: - logger.info(f"{server_name}: No persistent connection found. Skipping server shutdown") + logger.info(f"{server_name}: No attached runtime found. Skipping shutdown") async def reconnect_server( self, server_name: str, - client_session_factory: ClientSessionFactory, *, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None = None, trigger_oauth: bool | None = None, oauth_event_handler: OAuthEventHandler | None = None, allow_oauth_paste_fallback: bool = True, ) -> "ServerConnection": """ - Force reconnection to a server by disconnecting and re-establishing the connection. + Replace a server runtime after a transport or legacy-session failure. - This is used when a session has been terminated (e.g., 404 from server restart) - and we need to create a fresh connection with a new session. + Modern MCP has no durable protocol session; reconnecting replaces local + client and transport resources. Args: server_name: Name of the server to reconnect - client_session_factory: Factory function to create client sessions Returns: The new ServerConnection instance @@ -1783,7 +1749,7 @@ async def reconnect_server( server_conn = await self._launch_and_wait_for_server( server_name=server_name, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, trigger_oauth=trigger_oauth, oauth_event_handler=oauth_event_handler, @@ -1797,7 +1763,7 @@ async def reconnect_server( server_conn = await self._retry_server_with_oauth( server_name=server_name, server_conn=server_conn, - client_session_factory=client_session_factory, + callback_runtime=callback_runtime, startup_timeout_seconds=startup_timeout_seconds, oauth_event_handler=oauth_event_handler, allow_oauth_paste_fallback=allow_oauth_paste_fallback, diff --git a/src/fast_agent/mcp/sampling.py b/src/fast_agent/mcp/sampling.py index 1e4b89091..7378a7582 100644 --- a/src/fast_agent/mcp/sampling.py +++ b/src/fast_agent/mcp/sampling.py @@ -4,13 +4,14 @@ """ from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING -from mcp.client.session import ClientRequestContext from mcp_types import ( + INTERNAL_ERROR, CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, + ErrorData, TextContent, ) @@ -26,21 +27,16 @@ ) from fast_agent.interfaces import FastAgentLLMProtocol from fast_agent.llm.sampling_converter import SamplingConverter -from fast_agent.mcp.helpers.server_config_helpers import get_server_config from fast_agent.types.llm_stop_reason import LlmStopReason if TYPE_CHECKING: + from fast_agent.config import MCPServerSettings from fast_agent.context import Context from fast_agent.types import PromptMessageExtended logger = get_logger(__name__) -@runtime_checkable -class _NamedSamplingSession(Protocol): - session_server_name: str | None - - @dataclass(frozen=True, slots=True) class _SamplingModelSelection: model: str @@ -49,14 +45,16 @@ class _SamplingModelSelection: def create_sampling_llm( - params: CreateMessageRequestParams, model_string: str, api_key: str | None + params: CreateMessageRequestParams, + model_string: str, + api_key: str | None, + app_context: "Context | None", ) -> FastAgentLLMProtocol: """ Create an LLM instance for sampling without tools support. This utility function creates a minimal LLM instance based on the model string. Args: - mcp_ctx: The MCP ClientSession model_string: The model to use (e.g. "passthrough", "claude-3-5-sonnet-latest") Returns: @@ -64,14 +62,6 @@ def create_sampling_llm( """ from fast_agent.llm.model_factory import ModelFactory - app_context = None - try: - from fast_agent.context import get_current_context - - app_context = get_current_context() - except Exception: - logger.warning("App context not available for sampling call") - agent = LlmAgent( config=sampling_agent_config(params), context=app_context, @@ -96,13 +86,6 @@ def _current_app_context() -> "Context | None": return None -def _sampling_server_name(context: ClientRequestContext) -> str: - session = context.session - if isinstance(session, _NamedSamplingSession) and session.session_server_name: - return session.session_server_name - return "unknown" - - def _start_sampling_notification(server_name: str) -> None: try: from fast_agent.ui import notification_tracker @@ -129,23 +112,17 @@ def resolve_auto_sampling_enabled(app_context: "Context | None") -> bool: return app_context.config.auto_sampling -def _configured_sampling_model(context: ClientRequestContext) -> str | None: - server_config = get_server_config(context) +def _configured_sampling_model(server_config: "MCPServerSettings | None") -> str | None: if server_config and server_config.sampling: return server_config.sampling.model return None def _agent_sampling_overrides( - context: ClientRequestContext, + agent_model: str | None, + api_key: str | None, ) -> tuple[str | None, str | None]: - from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession - - if not isinstance(context.session, MCPAgentClientSession): - return None, None - - model = context.session.agent_model - api_key = context.session.api_key + model = agent_model if model: logger.debug(f"Using agent's model for sampling: {model}") if api_key: @@ -153,7 +130,7 @@ def _agent_sampling_overrides( return model, api_key -def _default_sampling_model(app_context: Any | None) -> str | None: +def _default_sampling_model(app_context: "Context | None") -> str | None: try: resolved_model = resolve_model_spec( app_context, @@ -171,14 +148,16 @@ def _default_sampling_model(app_context: Any | None) -> str | None: def _select_sampling_model( - context: ClientRequestContext, + *, + server_config: "MCPServerSettings | None", + agent_model: str | None, + api_key: str | None, + app_context: "Context | None", ) -> _SamplingModelSelection: - app_context = _current_app_context() - model = _configured_sampling_model(context) - api_key: str | None = None + model = _configured_sampling_model(server_config) if model is None and resolve_auto_sampling_enabled(app_context): - model, api_key = _agent_sampling_overrides(context) + model, api_key = _agent_sampling_overrides(agent_model, api_key) if model is None: model = _default_sampling_model(app_context) @@ -215,8 +194,14 @@ def _sampling_response( async def sample( - context: ClientRequestContext, params: CreateMessageRequestParams -) -> CreateMessageResult | CreateMessageResultWithTools: + params: CreateMessageRequestParams, + *, + server_name: str, + server_config: "MCPServerSettings | None", + agent_model: str | None, + api_key: str | None, + app_context: "Context | None", +) -> CreateMessageResult | CreateMessageResultWithTools | ErrorData: """ Handle sampling requests from the MCP protocol using SamplingConverter. @@ -232,7 +217,6 @@ async def sample( and sending follow-up requests with tool results. Args: - context: The MCP RequestContext containing the ClientSession params: The sampling request parameters (may include tools and toolChoice) Returns: @@ -240,16 +224,25 @@ async def sample( CreateMessageResultWithTools when the LLM wants to use tools """ # Get server name for notification tracking - server_name = _sampling_server_name(context) _start_sampling_notification(server_name) model: str | None = None try: - selection = _select_sampling_model(context) + selection = _select_sampling_model( + server_config=server_config, + agent_model=agent_model, + api_key=api_key, + app_context=app_context, + ) model = selection.model # Create an LLM instance - llm = create_sampling_llm(params, model, selection.api_key) + llm = create_sampling_llm( + params, + model, + selection.api_key, + selection.app_context, + ) # Extract all messages from the request params if not params.messages: @@ -262,8 +255,8 @@ async def sample( request_params = SamplingConverter.extract_request_params(params) # Check if tools are provided in the request - tools = params.tools if params.tools else None - has_tools = bool(tools) + tools = params.tools + has_tools = params.tools is not None or params.tool_choice is not None # Call LLM with tools if provided llm_response: PromptMessageExtended = await llm.generate( @@ -278,8 +271,9 @@ async def sample( return _sampling_response(llm_response, model=model, has_tools=has_tools) except Exception as e: logger.error(f"Error in sampling: {e!s}") - return SamplingConverter.error_result( - error_message=f"Error in sampling: {e!s}", model=model + return ErrorData( + code=INTERNAL_ERROR, + message=f"Error in sampling: {e!s}", ) finally: _end_sampling_notification(server_name) diff --git a/src/fast_agent/mcp_server_registry.py b/src/fast_agent/mcp_server_registry.py index 3f1046aca..dc7d66fb0 100644 --- a/src/fast_agent/mcp_server_registry.py +++ b/src/fast_agent/mcp_server_registry.py @@ -9,21 +9,19 @@ from __future__ import annotations -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - -from mcp.client._probe import negotiate_auto +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import TYPE_CHECKING, Any, cast from fast_agent.core.logging.logger import get_logger +from fast_agent.mcp.client_connection import MCPClientConnection, MCPTransportAdapter if TYPE_CHECKING: from collections.abc import AsyncIterator - from mcp import ClientSession from mcp_types import ServerCapabilities from fast_agent.config import MCPServerSettings, Settings - from fast_agent.mcp.interfaces import ClientSessionFactory + from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime logger = get_logger(__name__) @@ -81,9 +79,9 @@ def get_server_capabilities(self, server_name: str) -> "ServerCapabilities | Non async def initialize_server( self, server_name: str, - client_session_factory: ClientSessionFactory | None = None, + callback_runtime: MCPClientCallbackRuntime | None = None, trigger_oauth: bool | None = None, - ) -> AsyncIterator[ClientSession]: + ) -> AsyncIterator[MCPClientConnection]: """ Create a temporary connection to a server, initialize the session, and yield it. @@ -95,9 +93,8 @@ async def initialize_server( Args: server_name: Name of the server to initialize. - client_session_factory: Optional factory for creating the ClientSession. + callback_runtime: Optional fast-agent callback configuration. """ - from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from fast_agent.mcp.mcp_connection_manager import ( _is_http_auth_challenge_error, _resolve_oauth_mode, @@ -111,8 +108,10 @@ async def initialize_server( oauth_mode = _resolve_oauth_mode(config, trigger_oauth=trigger_oauth) @asynccontextmanager - async def _initialized_session(oauth_enabled: bool) -> AsyncIterator[ClientSession]: - transport_context = create_transport_context( + async def _initialized_session( + oauth_enabled: bool, + ) -> AsyncIterator[MCPClientConnection]: + legacy_transport = create_transport_context( server_name=server_name, config=config, trigger_oauth=oauth_enabled, @@ -120,25 +119,23 @@ async def _initialized_session(oauth_enabled: bool) -> AsyncIterator[ClientSessi no_home=bool(getattr(self._config, "_fast_agent_no_home", False)), ) - async with transport_context as (read_stream, write_stream, _get_session_id_cb): - read_timeout = config.read_timeout_seconds - if client_session_factory is not None: - session = client_session_factory( - read_stream, - write_stream, - read_timeout, - server_config=config, - ) - else: - session = MCPAgentClientSession( - read_stream, write_stream, read_timeout, server_config=config - ) - - async with session: - await negotiate_auto(session) - if session.server_capabilities is not None: - self._capabilities[server_name] = session.server_capabilities - yield session + # Import lazily to keep the registry usable while Context is being + # constructed; the callback runtime depends on agent configuration. + from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime + + callbacks = callback_runtime or MCPClientCallbackRuntime( + server_name=server_name, server_config=config + ) + connection = MCPClientConnection( + MCPTransportAdapter(cast("AbstractAsyncContextManager[tuple[Any, Any, Any]]", legacy_transport)), + callbacks, + read_timeout_seconds=config.read_timeout_seconds, + cache=False, + ) + async with connection: + if connection.server_capabilities is not None: + self._capabilities[server_name] = connection.server_capabilities + yield connection try: async with _initialized_session(oauth_mode == "force") as session: diff --git a/tests/integration/api/test_mcp_oauth_auto_escalation.py b/tests/integration/api/test_mcp_oauth_auto_escalation.py index 09ec31dd5..17ea76d81 100644 --- a/tests/integration/api/test_mcp_oauth_auto_escalation.py +++ b/tests/integration/api/test_mcp_oauth_auto_escalation.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING -import httpx +import httpx2 as httpx import pytest from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse from fast_agent.config import MCPServerSettings, MCPSettings, Settings -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession +from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime from fast_agent.mcp.mcp_connection_manager import MCPConnectionManager from fast_agent.mcp_server_registry import ServerRegistry @@ -104,23 +104,27 @@ async def test_http_mcp_auto_escalates_to_oauth_on_401( monkeypatch: pytest.MonkeyPatch, ) -> None: app, initialize_auth_headers = _build_app() + async_client = httpx.AsyncClient def _client_factory( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, auth: httpx.Auth | None = None, + follow_redirects: bool = True, + event_hooks: dict[str, list] | None = None, ) -> httpx.AsyncClient: - return httpx.AsyncClient( + return async_client( transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers=headers, timeout=timeout, auth=auth, - follow_redirects=True, + follow_redirects=follow_redirects, + event_hooks=event_hooks, ) monkeypatch.setattr( - "fast_agent.mcp.mcp_connection_manager.create_mcp_http_client", + "fast_agent.mcp.mcp_connection_manager.httpx2.AsyncClient", _client_factory, ) monkeypatch.setattr( @@ -140,11 +144,16 @@ def _client_factory( ) ) registry = ServerRegistry(config=settings) + server_config = registry.get_server_config("authsrv") + assert server_config is not None async with MCPConnectionManager(registry) as manager: server_conn = await manager.get_server( "authsrv", - client_session_factory=MCPAgentClientSession, + callback_runtime=MCPClientCallbackRuntime( + server_name="authsrv", + server_config=server_config, + ), startup_timeout_seconds=5.0, ) @@ -152,6 +161,6 @@ def _client_factory( assert manager._oauth_required_servers == {"authsrv"} assert initialize_auth_headers == [None, "Bearer test-token"] - assert server_conn.session is not None - tools = await server_conn.session.list_tools() + assert server_conn.client is not None + tools = await server_conn.client.list_tools() assert [tool.name for tool in tools.tools] == ["echo"] diff --git a/tests/integration/mcp_2026/subscription_server.py b/tests/integration/mcp_2026/subscription_server.py index 21e8caccf..3b973699e 100644 --- a/tests/integration/mcp_2026/subscription_server.py +++ b/tests/integration/mcp_2026/subscription_server.py @@ -2,7 +2,19 @@ from mcp.server.mcpserver import Context, MCPServer -server = MCPServer("modern-subscriptions") + +class CachingMCPServer(MCPServer): + list_tools_calls = 0 + + async def _handle_list_tools(self, ctx, params): # noqa: ANN001, ANN201 + self.list_tools_calls += 1 + result = await super()._handle_list_tools(ctx, params) + result.ttl_ms = 60_000 + result.cache_scope = "private" + return result + + +server = CachingMCPServer("modern-subscriptions") @server.tool() @@ -17,6 +29,11 @@ def dynamic_echo(message: str) -> str: return "added" +@server.tool() +def list_tools_call_count() -> int: + return server.list_tools_calls + + if __name__ == "__main__": server.run( transport="streamable-http", diff --git a/tests/integration/mcp_2026/test_modern_subscriptions.py b/tests/integration/mcp_2026/test_modern_subscriptions.py index 24b4ed0e0..f9f3624fe 100644 --- a/tests/integration/mcp_2026/test_modern_subscriptions.py +++ b/tests/integration/mcp_2026/test_modern_subscriptions.py @@ -3,6 +3,14 @@ import subprocess import pytest +from mcp_types import TextContent + + +async def _list_tools_call_count(client) -> int: + result = await client.call_tool("list_tools_call_count") + content = result.content[0] + assert isinstance(content, TextContent) + return int(content.text) @pytest.mark.integration @@ -29,6 +37,15 @@ async def run_probe() -> None: before = {tool.name for tool in (await app.probe.list_tools()).tools} assert "modern_http__dynamic_echo" not in before + manager = app.probe._aggregator._persistent_connection_manager + assert manager is not None + connection = manager.running_servers["modern_http"] + assert connection.client is not None + list_count = await _list_tools_call_count(connection.client) + await connection.client.list_tools() + await connection.client.list_tools() + assert await _list_tools_call_count(connection.client) == list_count + status = (await app.probe.get_server_status())["modern_http"] assert status.protocol_era == "modern" assert status.subscription_state == "open" diff --git a/tests/unit/fast_agent/mcp/test_client_callback_runtime.py b/tests/unit/fast_agent/mcp/test_client_callback_runtime.py new file mode 100644 index 000000000..db8eacf24 --- /dev/null +++ b/tests/unit/fast_agent/mcp/test_client_callback_runtime.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from mcp_types import ( + CreateMessageRequestParams, + ElicitRequestURLParams, + ElicitResult, + ListRootsResult, + SamplingMessage, + TextContent, + ToolListChangedNotification, +) + +from fast_agent.config import ( + MCPElicitationSettings, + MCPRootSettings, + MCPSamplingSettings, + MCPServerSettings, + Settings, +) +from fast_agent.context import Context +from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime +from fast_agent.mcp.mcp_aggregator import MCPAggregator + + +def _context(*, auto_sampling: bool = False, elicitation_mode: str = "forms") -> Context: + return Context( + config=Settings.model_validate( + { + "auto_sampling": auto_sampling, + "elicitation": {"mode": elicitation_mode}, + } + ) + ) + + +@pytest.mark.asyncio +async def test_runtime_exposes_roots_without_reading_sdk_session() -> None: + runtime = MCPClientCallbackRuntime( + server_name="filesystem", + server_config=MCPServerSettings( + roots=[ + MCPRootSettings( + uri="file:///workspace", + server_uri_alias="file:///presented-workspace", + name="workspace", + ) + ] + ), + context=_context(), + ) + + assert runtime.list_roots_callback is not None + request_context: Any = None + result = await runtime.list_roots_callback(request_context) + + assert isinstance(result, ListRootsResult) + assert len(result.roots) == 1 + assert str(result.roots[0].uri) == "file:///presented-workspace" + assert result.roots[0].name == "workspace" + assert runtime.sampling_callback is None + assert runtime.sampling_capabilities is None + + +@pytest.mark.asyncio +async def test_runtime_sampling_callback_captures_fast_agent_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def fake_sample( + params: CreateMessageRequestParams, + *, + server_name: str, + server_config: MCPServerSettings | None, + agent_model: str | None, + api_key: str | None, + app_context: Context | None, + ) -> object: + captured.update( + params=params, + server_name=server_name, + server_config=server_config, + agent_model=agent_model, + api_key=api_key, + app_context=app_context, + ) + return "sampled" + + monkeypatch.setattr("fast_agent.mcp.client_callback_runtime.sample", fake_sample) + app_context = _context() + server_config = MCPServerSettings(sampling=MCPSamplingSettings(model="configured-model")) + runtime = MCPClientCallbackRuntime( + server_name="sampling-server", + server_config=server_config, + agent_model="agent-model", + api_key="agent-key", + context=app_context, + ) + params = CreateMessageRequestParams( + max_tokens=64, + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + ) + + assert runtime.sampling_callback is not None + request_context: Any = None + result = await runtime.sampling_callback(request_context, params) + + assert result == "sampled" + assert captured == { + "params": params, + "server_name": "sampling-server", + "server_config": server_config, + "agent_model": "agent-model", + "api_key": "agent-key", + "app_context": app_context, + } + assert runtime.sampling_capabilities is not None + + +@pytest.mark.asyncio +async def test_runtime_forms_callback_queues_url_elicitation() -> None: + runtime = MCPClientCallbackRuntime( + server_name="identity", + server_config=MCPServerSettings( + command="identity-server", + elicitation=MCPElicitationSettings(mode="forms"), + ), + agent_name="researcher", + context=_context(), + ) + params = ElicitRequestURLParams( + mode="url", + message="Authenticate to continue", + url="https://example.com/auth", + elicitation_id="url-1", + ) + + assert runtime.elicitation_callback is not None + request_context: Any = None + result = await runtime.elicitation_callback(request_context, params) + + assert isinstance(result, ElicitResult) + assert result.action == "accept" + queued = runtime.consume_pending_url_elicitations() + assert len(queued) == 1 + assert queued[0].message == "Authenticate to continue" + assert queued[0].url == "https://example.com/auth" + assert queued[0].elicitation_id == "url-1" + + +@pytest.mark.asyncio +async def test_runtime_forwards_server_notifications_to_aggregator() -> None: + received: list[tuple[str, object]] = [] + + async def notify(server_name: str, notification: object) -> None: + received.append((server_name, notification)) + + aggregator = object.__new__(MCPAggregator) + aggregator.server_notification_callback = notify + + runtime = MCPClientCallbackRuntime( + server_name="notifier", + server_config=None, + aggregator=aggregator, + context=_context(), + ) + notification = ToolListChangedNotification() + + await runtime.message_handler(notification) + await asyncio.sleep(0) + + assert received == [("notifier", notification)] diff --git a/tests/unit/fast_agent/mcp/test_elicitation_handlers.py b/tests/unit/fast_agent/mcp/test_elicitation_handlers.py index e742ce5ef..400eed134 100644 --- a/tests/unit/fast_agent/mcp/test_elicitation_handlers.py +++ b/tests/unit/fast_agent/mcp/test_elicitation_handlers.py @@ -2,15 +2,17 @@ from typing import Any import pytest -from mcp_types import CallToolResult, ElicitRequestURLParams +from mcp_types import ElicitRequestURLParams, ElicitResult from fast_agent.human_input.types import HumanInputResponse +from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime from fast_agent.mcp.elicitation_handlers import ( _parse_elicitation_content, - forms_elicitation_handler, ) -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession -from fast_agent.mcp.tool_result_metadata import set_url_elicitation_required_payload +from fast_agent.mcp.tool_result_metadata import ( + set_url_elicitation_required_payload, + url_elicitation_required_payload, +) from fast_agent.mcp.url_elicitation_required import ( URLElicitationDisplayItem, URLElicitationRequiredDisplayPayload, @@ -19,7 +21,7 @@ @dataclass class _ContextWithSession: - session: MCPAgentClientSession + session: object def _response(value: str) -> HumanInputResponse: @@ -52,7 +54,7 @@ def test_url_elicitation_payload_round_trips_on_builtin_exception() -> None: set_url_elicitation_required_payload(exc, payload) - assert MCPAgentClientSession.get_url_elicitation_required_payload(exc) is payload + assert url_elicitation_required_payload(exc) is payload @pytest.mark.parametrize("payload", ['["Ada"]', '"Ada"', "42", "true"]) @@ -131,13 +133,13 @@ def test_parse_elicitation_content_keeps_unknown_single_field_type_as_text() -> @pytest.mark.asyncio async def test_forms_handler_defers_url_elicitation_to_result_payload(capsys) -> None: - session = object.__new__(MCPAgentClientSession) - session.session_server_name = "session-server" - session.server_config = None - session.agent_name = "test-agent" - session._pending_url_elicitations = [] + runtime = MCPClientCallbackRuntime( + server_name="session-server", + server_config=None, + agent_name="test-agent", + ) - context: Any = _ContextWithSession(session=session) + context: Any = _ContextWithSession(session=object()) params = ElicitRequestURLParams( mode="url", message="Open browser to continue", @@ -145,23 +147,17 @@ async def test_forms_handler_defers_url_elicitation_to_result_payload(capsys) -> elicitation_id="form-url-1", ) - result = await forms_elicitation_handler(context, params) + callback = runtime.elicitation_callback + assert callback is not None + result = await callback(context, params) + assert isinstance(result, ElicitResult) assert result.action == "accept" captured = capsys.readouterr() assert captured.out.strip() == "" - tool_result = CallToolResult(content=[], is_error=False) - session._attach_pending_url_elicitation_payload_for_request( - tool_result, - request_method="tools/call", - ) - - payload = MCPAgentClientSession.get_url_elicitation_required_payload(tool_result) - assert payload is not None - assert payload.server_name == "session-server" - assert payload.request_method == "tools/call" - assert len(payload.elicitations) == 1 - assert payload.elicitations[0].message == "Open browser to continue" - assert payload.elicitations[0].url == "https://example.com/continue" - assert payload.elicitations[0].elicitation_id == "form-url-1" + items = runtime.consume_pending_url_elicitations() + assert len(items) == 1 + assert items[0].message == "Open browser to continue" + assert items[0].url == "https://example.com/continue" + assert items[0].elicitation_id == "form-url-1" diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py index ad3d2f8c3..5dc853484 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_metadata_passthrough.py @@ -1,19 +1,11 @@ from __future__ import annotations from types import SimpleNamespace -from typing import Any, cast +from typing import Any import pytest -from mcp_types import ( - CallToolRequest, - CallToolResult, - ClientRequest, - RequestParamsMeta, - TextContent, -) from fast_agent.llm.fastagent_llm import _mcp_metadata_var -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from fast_agent.mcp.mcp_aggregator import MCPAggregator @@ -29,10 +21,7 @@ async def read_resource(self, **kwargs: Any) -> Any: self.last_kwargs = dict(kwargs) return "ok-read" - call_tool_complete = call_tool - read_resource_complete = read_resource - - async def get_prompt_complete(self, **kwargs: Any) -> Any: + async def get_prompt(self, **kwargs: Any) -> Any: self.last_kwargs = dict(kwargs) return "ok-prompt" @@ -41,54 +30,9 @@ class _FakeConnectionManager: def __init__(self, session: _RecordingSession) -> None: self._session = session - async def get_server(self, server_name: str, client_session_factory) -> SimpleNamespace: - del server_name, client_session_factory - return SimpleNamespace(session=self._session) - - -class _RawCallToolSession(MCPAgentClientSession): - _call_tool_adapter = object() - - def __init__(self) -> None: - self.last_request: ClientRequest | None = None - self.last_timeout = None - self._tool_output_schemas = {"legacy_tool": None} - - async def send_request( - self, - request, - result_type, - request_read_timeout_seconds=None, - metadata=None, - progress_callback=None, - ): - del result_type, metadata, progress_callback - self.last_request = request - self.last_timeout = request_read_timeout_seconds - return CallToolResult(content=[TextContent(type="text", text="legacy result")]) - - -@pytest.mark.asyncio -async def test_client_session_call_tool_uses_raw_request_path_with_meta() -> None: - session = _RawCallToolSession() - metadata = cast("RequestParamsMeta", {"trace": {"id": "abc"}}) - - result = await session.call_tool( - name="legacy_tool", - arguments={"value": 1}, - read_timeout_seconds=3, - meta=metadata, - ) - - assert result.content == [TextContent(type="text", text="legacy result")] - assert session.last_timeout == 3 - assert session.last_request is not None - request = cast("CallToolRequest", session.last_request) - assert request.method == "tools/call" - assert request.params.name == "legacy_tool" - assert request.params.arguments == {"value": 1} - assert request.params.meta is not None - assert request.params.meta == metadata + async def get_server(self, server_name: str, callback_runtime) -> SimpleNamespace: + del server_name, callback_runtime + return SimpleNamespace(client=self._session) @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py index 54bfa76b8..283e2df6c 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py @@ -7,8 +7,6 @@ from mcp_types import ( CallToolResult, ErrorData, - Implementation, - InitializeResult, ListPromptsResult, ListToolsResult, Prompt, @@ -34,8 +32,6 @@ from fast_agent.mcp_server_registry import ServerRegistry if TYPE_CHECKING: - from mcp.client.session import ClientSession - from fast_agent.mcp.mcp_connection_manager import MCPConnectionManager @@ -45,37 +41,6 @@ def _build_context(configs: dict[str, MCPServerSettings]) -> Context: return Context(server_registry=registry) -class _DummySession: - """Minimal stub that records initialize() calls.""" - - def __init__(self) -> None: - self.initialized = False - self.closed = False - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - self.closed = True - return None - - async def initialize(self): - self.initialized = True - return InitializeResult( - protocol_version="2025-03-26", - capabilities=ServerCapabilities(tools=ToolsCapability()), - server_info=Implementation(name="stub", version="0.1"), - ) - - async def send_discover(self, version: str): - del version - raise McpError(-32601, "Method not found") - - @property - def server_capabilities(self) -> ServerCapabilities | None: - return ServerCapabilities(tools=ToolsCapability()) if self.initialized else None - - def _make_stub_aggregator( context: Context, server_name: str, @@ -117,50 +82,85 @@ async def _execute_on_server( @pytest.mark.asyncio -async def test_initialize_server_creates_and_tears_down_session(monkeypatch) -> None: +async def test_initialize_server_uses_public_client_and_two_stream_transport(monkeypatch) -> None: registry = ServerRegistry() - registry.registry = { - "demo": MCPServerSettings(name="demo", transport="stdio", command="echo"), - } + server_config = MCPServerSettings(name="demo", transport="stdio", command="echo") + registry.registry = {"demo": server_config} - session = _DummySession() - transport_entered = False - transport_exited = False + entered_streams: tuple[object, object] | None = None + session = object() + capabilities = ServerCapabilities(tools=ToolsCapability()) + tool_result = CallToolResult(content=[TextContent(type="text", text="ok")]) + call_tool_args: tuple[object, ...] | None = None @asynccontextmanager async def _fake_transport(server_name, config, trigger_oauth=None, **kwargs): - del kwargs - del trigger_oauth - nonlocal transport_entered, transport_exited - transport_entered = True - yield (object(), object(), None) - transport_exited = True + del server_name, config, trigger_oauth, kwargs + yield (object(), object(), lambda: "legacy-session-id") + + class _FakeConnection: + def __init__(self, transport, callbacks, **kwargs) -> None: + self._transport = transport + self.callbacks = callbacks + self.kwargs = kwargs + + async def __aenter__(self): + nonlocal entered_streams + entered_streams = await self._transport.__aenter__() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self._transport.__aexit__(exc_type, exc_value, traceback) + + @property + def session(self): + return session + + @property + def server_capabilities(self): + return capabilities + + async def call_tool( + self, + name, + arguments=None, + read_timeout_seconds=None, + progress_callback=None, + *, + meta=None, + ): + nonlocal call_tool_args + call_tool_args = ( + name, + arguments, + read_timeout_seconds, + progress_callback, + meta, + ) + return tool_result monkeypatch.setattr( "fast_agent.mcp.mcp_connection_manager.create_transport_context", _fake_transport, ) + monkeypatch.setattr("fast_agent.mcp_server_registry.MCPClientConnection", _FakeConnection) - def _fake_factory(read_stream, write_stream, read_timeout, **kwargs): - return session + async with registry.initialize_server("demo") as connection: + assert connection.session is session + assert connection.server_capabilities is capabilities + assert entered_streams is not None + assert len(entered_streams) == 2 + assert await connection.call_tool("echo", {"value": "test"}) is tool_result - async with registry.initialize_server( - "demo", client_session_factory=_fake_factory - ) as yielded_session: - assert yielded_session is session - assert session.initialized is True - assert transport_entered is True - - assert session.closed is True - assert transport_exited is True - assert registry.get_server_capabilities("demo") is not None + assert call_tool_args == ("echo", {"value": "test"}, None, None, None) + assert registry.get_server_capabilities("demo") is capabilities @pytest.mark.asyncio async def test_handle_auth_challenge_reports_retry_failure() -> None: class _FailingManager: - async def reconnect_server(self, server_name, client_session_factory, trigger_oauth=None): - del server_name, client_session_factory, trigger_oauth + async def reconnect_server(self, server_name, callback_runtime, trigger_oauth=None): + del server_name, callback_runtime, trigger_oauth raise RuntimeError("OAuth callback timed out") aggregator = MCPAggregator( @@ -185,40 +185,6 @@ async def _try_execute(session) -> None: assert result == "OAuth callback timed out" -@pytest.mark.asyncio -async def test_initialize_server_forwards_server_config_to_custom_factory(monkeypatch) -> None: - registry = ServerRegistry() - server_config = MCPServerSettings(name="demo", transport="stdio", command="echo") - registry.registry = {"demo": server_config} - - session = _DummySession() - captured_server_config = None - - @asynccontextmanager - async def _fake_transport(server_name, config, trigger_oauth=None, **kwargs): - del kwargs - del trigger_oauth - yield (object(), object(), None) - - monkeypatch.setattr( - "fast_agent.mcp.mcp_connection_manager.create_transport_context", - _fake_transport, - ) - - def _fake_factory(read_stream, write_stream, read_timeout, **kwargs): - del read_stream, write_stream, read_timeout - nonlocal captured_server_config - captured_server_config = kwargs.get("server_config") - return session - - async with registry.initialize_server( - "demo", client_session_factory=_fake_factory - ) as yielded_session: - assert yielded_session is session - - assert captured_server_config is server_config - - @pytest.mark.asyncio async def test_initialize_server_retries_with_oauth_after_401(monkeypatch) -> None: registry = ServerRegistry() @@ -240,26 +206,31 @@ async def _fake_transport(server_name, config, trigger_oauth=None, **kwargs): _fake_transport, ) - class _ChallengeSession(_DummySession): - def __init__(self, oauth_enabled: bool) -> None: - super().__init__() - self._oauth_enabled = oauth_enabled + class _ChallengeConnection: + def __init__(self, transport, callbacks, **kwargs) -> None: + del callbacks, kwargs + self._transport = transport - async def initialize(self): - if not self._oauth_enabled: + async def __aenter__(self): + await self._transport.__aenter__() + if not trigger_history[-1]: raise RuntimeError("401 Unauthorized") - return await super().initialize() + return self - def _fake_factory(read_stream, write_stream, read_timeout, **kwargs): - del read_stream, write_stream, read_timeout, kwargs - return _ChallengeSession(oauth_enabled=bool(trigger_history[-1])) + async def __aexit__(self, exc_type, exc_value, traceback): + return await self._transport.__aexit__(exc_type, exc_value, traceback) - async with registry.initialize_server( - "demo", client_session_factory=_fake_factory - ) as session: - assert isinstance(session, _ChallengeSession) - assert session.initialized is True + @property + def server_capabilities(self): + return ServerCapabilities(tools=ToolsCapability()) + monkeypatch.setattr( + "fast_agent.mcp_server_registry.MCPClientConnection", + _ChallengeConnection, + ) + + async with registry.initialize_server("demo"): + pass assert trigger_history == [False, True] @@ -295,11 +266,11 @@ async def list_tools(self) -> ListToolsResult: async def _fake_gen_client( server_name, server_registry, - client_session_factory=_DummySession, *, + callback_runtime=None, trigger_oauth=None, ): - del server_name, server_registry, client_session_factory + del server_name, server_registry, callback_runtime trigger_history.append(trigger_oauth) yield _RetryClient(trigger_oauth) @@ -347,12 +318,10 @@ async def call_tool(self, **kwargs): # noqa: ANN003 del kwargs return CallToolResult(content=[TextContent(type="text", text="ok")]) - call_tool_complete = call_tool - - async def read_resource_complete(self, **kwargs): # noqa: ANN003 + async def read_resource(self, **kwargs): # noqa: ANN003 raise AssertionError(kwargs) - async def get_prompt_complete(self, **kwargs): # noqa: ANN003 + async def get_prompt(self, **kwargs): # noqa: ANN003 raise AssertionError(kwargs) gen_client_calls: list[str] = [] @@ -361,11 +330,11 @@ async def get_prompt_complete(self, **kwargs): # noqa: ANN003 async def _fake_gen_client( server_name, server_registry, - client_session_factory=_DummySession, *, + callback_runtime=None, trigger_oauth=None, ): - del server_registry, client_session_factory, trigger_oauth + del server_registry, callback_runtime, trigger_oauth gen_client_calls.append(server_name) yield _RequestClient() @@ -387,34 +356,6 @@ async def _fake_gen_client( assert isinstance(result, CallToolResult) assert gen_client_calls == ["hf"] - -@pytest.mark.asyncio -async def test_execute_session_method_rejects_session_without_completed_mrtr_operations() -> None: - aggregator = MCPAggregator( - server_names=[], - connection_persistence=False, - context=_build_context({}), - ) - - class _IncompleteSession: - async def call_tool(self, **kwargs): # noqa: ANN003 - raise AssertionError(kwargs) - - with pytest.raises( - TypeError, - match="MCP session factory must provide completed MRTR operations", - ): - await aggregator._execute_session_method( - cast("ClientSession", _IncompleteSession()), - server_name="alpha", - operation_name="echo", - method_name="call_tool", - method_args={"name": "echo", "arguments": {}}, - error_factory=None, - progress_callback=None, - ) - - # --------------------------------------------------------------------------- # get_capabilities (non-persistent path) # --------------------------------------------------------------------------- @@ -431,10 +372,12 @@ async def test_get_capabilities_nonpersistent_returns_real_capabilities( expected_caps = ServerCapabilities(tools=ToolsCapability(), prompts=PromptsCapability()) @asynccontextmanager - async def _fake_initialize_server(self, server_name, client_session_factory=None, trigger_oauth=None): - del trigger_oauth + async def _fake_initialize_server( + self, server_name, callback_runtime=None, trigger_oauth=None + ): + del callback_runtime, trigger_oauth self._capabilities[server_name] = expected_caps - yield _DummySession() + yield object() monkeypatch.setattr( ServerRegistry, @@ -462,12 +405,14 @@ async def test_get_capabilities_nonpersistent_caches_result(monkeypatch) -> None init_count = 0 @asynccontextmanager - async def _counting_initialize(self, server_name, client_session_factory=None, trigger_oauth=None): - del trigger_oauth + async def _counting_initialize( + self, server_name, callback_runtime=None, trigger_oauth=None + ): + del callback_runtime, trigger_oauth nonlocal init_count init_count += 1 self._capabilities[server_name] = expected_caps - yield _DummySession() + yield object() monkeypatch.setattr( ServerRegistry, @@ -504,8 +449,8 @@ async def __aexit__(self, exc_type, exc, tb) -> bool: del exc_type, exc, tb return False - def _exploding_initialize(self, server_name, client_session_factory=None, trigger_oauth=None): - del self, server_name, client_session_factory, trigger_oauth + def _exploding_initialize(self, server_name, callback_runtime=None, trigger_oauth=None): + del self, server_name, callback_runtime, trigger_oauth return _ExplodingInitialize() monkeypatch.setattr( @@ -635,10 +580,9 @@ class _DummyInitializer: """Stub implementing only ServerInitializerProtocol, not the full registry protocol.""" @asynccontextmanager - async def initialize_server(self, server_name, client_session_factory=None, trigger_oauth=None): - del trigger_oauth - session = _DummySession() - yield session + async def initialize_server(self, server_name, callback_runtime=None, trigger_oauth=None): + del server_name, callback_runtime, trigger_oauth + yield object() def get_server_capabilities(self, server_name): return None @@ -712,10 +656,12 @@ async def test_detach_server_clears_capabilities_cache(monkeypatch) -> None: expected_caps = ServerCapabilities(tools=ToolsCapability()) @asynccontextmanager - async def _fake_initialize_server(self, server_name, client_session_factory=None, trigger_oauth=None): - del trigger_oauth + async def _fake_initialize_server( + self, server_name, callback_runtime=None, trigger_oauth=None + ): + del callback_runtime, trigger_oauth self._capabilities[server_name] = expected_caps - yield _DummySession() + yield object() monkeypatch.setattr( ServerRegistry, @@ -777,14 +723,14 @@ def __init__(self) -> None: async def initialize_server( self, server_name, - client_session_factory=None, + callback_runtime=None, trigger_oauth=None, ): - del client_session_factory, trigger_oauth + del callback_runtime, trigger_oauth capabilities = capability_generations[min(self.initialize_count, 1)] self.initialize_count += 1 self._capabilities[server_name] = capabilities - yield _DummySession() + yield object() registry = _SequencedRegistry() context = Context(server_registry=registry) diff --git a/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py b/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py index 3fe90ac0e..6aa2f794d 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py +++ b/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py @@ -6,12 +6,11 @@ import pytest from anyio import create_task_group -from mcp import ClientSession from fast_agent.config import MCPServerAuthSettings, MCPServerSettings from fast_agent.core.exceptions import ServerInitializationError from fast_agent.mcp.auth.context import request_bearer_token -from fast_agent.mcp.interfaces import ClientSessionFactory +from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime from fast_agent.mcp.mcp_connection_manager import ( MCPConnectionManager, ServerConnection, @@ -248,24 +247,11 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc, tb): return None - class DummySession: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return None - - async def initialize(self): - raise RuntimeError("boom") - - def session_factory(*_args, **_kwargs): - return DummySession() - server_conn = ServerConnection( server_name="test-server", server_config=MCPServerSettings(name="test-server", url="http://example.com/mcp"), transport_context_factory=DummyTransportContext, - client_session_factory=session_factory, + callback_runtime=_callback_runtime(), ) lifecycle_task = asyncio.create_task(_server_lifecycle_task(server_conn)) @@ -285,29 +271,19 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc, tb): return None - class DummySession: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return None - - async def initialize(self): - return None - - def session_factory(*_args, **_kwargs): - return DummySession() - return ServerConnection( server_name="test-server", server_config=MCPServerSettings(name="test-server", url="http://example.com/mcp"), transport_context_factory=DummyTransportContext, - client_session_factory=session_factory, + callback_runtime=_callback_runtime(), ) -def _dummy_client_session_factory(*_args: Any, **_kwargs: Any) -> ClientSession: - return cast("ClientSession", object()) +def _callback_runtime() -> MCPClientCallbackRuntime: + return MCPClientCallbackRuntime( + server_name="test-server", + server_config=MCPServerSettings(name="test-server", url="http://example.com/mcp"), + ) @pytest.mark.asyncio @@ -391,13 +367,13 @@ async def test_get_server_cancellation_cleans_up_pending_connection( async def _fake_launch_server( *, server_name: str, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None = None, trigger_oauth: bool | None = None, oauth_event_handler: OAuthEventHandler | None = None, allow_oauth_paste_fallback: bool = True, ) -> ServerConnection: - del server_name, client_session_factory, startup_timeout_seconds + del server_name, callback_runtime, startup_timeout_seconds del trigger_oauth, oauth_event_handler, allow_oauth_paste_fallback manager.running_servers["demo"] = server_conn return server_conn @@ -407,7 +383,7 @@ async def _fake_launch_server( task = asyncio.create_task( manager.get_server( "demo", - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), startup_timeout_seconds=10.0, ) ) @@ -451,19 +427,19 @@ async def __aexit__(self, exc_type, exc, tb): url="http://127.0.0.1:9/mcp", ), transport_context_factory=HangingTransportContext, - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), ) async def _fake_launch_server( *, server_name: str, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None = None, trigger_oauth: bool | None = None, oauth_event_handler: OAuthEventHandler | None = None, allow_oauth_paste_fallback: bool = True, ) -> ServerConnection: - del server_name, client_session_factory, startup_timeout_seconds + del server_name, callback_runtime, startup_timeout_seconds del trigger_oauth, oauth_event_handler, allow_oauth_paste_fallback manager.running_servers["demo"] = server_conn asyncio.create_task(_server_lifecycle_task(server_conn)) @@ -475,7 +451,7 @@ async def _fake_launch_server( with pytest.raises(ServerInitializationError): await manager.get_server( "demo", - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), startup_timeout_seconds=0.01, ) @@ -495,21 +471,21 @@ async def test_get_server_retries_with_oauth_after_401_startup( unhealthy._error_message = "HTTP Error: 401 Unauthorized for URL: http://example.com/mcp" healthy = _make_server_connection() - healthy.session = cast("Any", object()) + healthy.client = cast("Any", object()) calls: list[bool | None] = [] async def _fake_launch_and_wait_for_server( *, server_name: str, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None, trigger_oauth: bool | None, oauth_event_handler: OAuthEventHandler | None, allow_oauth_paste_fallback: bool, timeout_action: str, ) -> ServerConnection: - del server_name, client_session_factory, startup_timeout_seconds + del server_name, callback_runtime, startup_timeout_seconds del oauth_event_handler, allow_oauth_paste_fallback, timeout_action trigger = trigger_oauth calls.append(trigger) @@ -521,13 +497,13 @@ async def _fake_retry_server_with_oauth( *, server_name: str, server_conn: ServerConnection, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None, oauth_event_handler: OAuthEventHandler | None, allow_oauth_paste_fallback: bool, timeout_action: str, ) -> ServerConnection: - del server_name, server_conn, client_session_factory, startup_timeout_seconds + del server_name, server_conn, callback_runtime, startup_timeout_seconds del oauth_event_handler, allow_oauth_paste_fallback, timeout_action calls.append(True) manager._server_oauth_mode["demo"] = "force" @@ -539,7 +515,7 @@ async def _fake_retry_server_with_oauth( server_conn = await manager.get_server( "demo", - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), ) assert server_conn is healthy @@ -584,7 +560,7 @@ def _failing_stdio_client(*_args, **_kwargs): with pytest.raises(ServerInitializationError) as exc_info: await manager.get_server( "demo", - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), startup_timeout_seconds=1.0, ) @@ -636,7 +612,7 @@ def _failing_stdio_client(*_args, **_kwargs): with pytest.raises(ServerInitializationError) as exc_info: await manager.get_server( "demo", - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), startup_timeout_seconds=1.0, ) @@ -662,7 +638,7 @@ async def test_get_server_stdio_timeout_includes_recent_stderr( server_name="demo", server_config=config, transport_context_factory=lambda: cast("Any", object()), - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), ) server_conn.record_stdio_stderr("npm notice downloading desktop-commander") server_conn.record_stdio_stderr("npm warn request took longer than expected") @@ -670,13 +646,13 @@ async def test_get_server_stdio_timeout_includes_recent_stderr( async def _fake_launch_server( *, server_name: str, - client_session_factory: ClientSessionFactory, + callback_runtime: MCPClientCallbackRuntime, startup_timeout_seconds: float | None = None, trigger_oauth: bool | None = None, oauth_event_handler: OAuthEventHandler | None = None, allow_oauth_paste_fallback: bool = True, ) -> ServerConnection: - del server_name, client_session_factory, startup_timeout_seconds + del server_name, callback_runtime, startup_timeout_seconds del trigger_oauth, oauth_event_handler, allow_oauth_paste_fallback manager.running_servers["demo"] = server_conn return server_conn @@ -686,7 +662,7 @@ async def _fake_launch_server( with pytest.raises(ServerInitializationError) as exc_info: await manager.get_server( "demo", - client_session_factory=_dummy_client_session_factory, + callback_runtime=_callback_runtime(), startup_timeout_seconds=0.01, ) diff --git a/tests/unit/fast_agent/mcp/test_server_config_helpers.py b/tests/unit/fast_agent/mcp/test_server_config_helpers.py deleted file mode 100644 index 7a4781312..000000000 --- a/tests/unit/fast_agent/mcp/test_server_config_helpers.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from fast_agent.config import MCPServerSettings -from fast_agent.mcp.helpers.server_config_helpers import get_server_config -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession - - -@dataclass(frozen=True, slots=True) -class _RequestContext: - session: object - - -def _session_with_config(server_config: MCPServerSettings | None) -> MCPAgentClientSession: - session = object.__new__(MCPAgentClientSession) - session.server_config = server_config - return session - - -def test_get_server_config_accepts_mcp_agent_client_session() -> None: - server_config = MCPServerSettings(name="docs") - session = _session_with_config(server_config) - - assert get_server_config(session) is server_config - - -def test_get_server_config_accepts_request_context_session() -> None: - server_config = MCPServerSettings(name="docs") - context = _RequestContext(session=_session_with_config(server_config)) - - assert get_server_config(context) is server_config - - -def test_get_server_config_ignores_unsupported_objects() -> None: - assert get_server_config(object()) is None - assert get_server_config(_RequestContext(session=object())) is None diff --git a/tests/unit/fast_agent/mcp/test_server_session_terminated.py b/tests/unit/fast_agent/mcp/test_server_session_terminated.py deleted file mode 100644 index f94fa475a..000000000 --- a/tests/unit/fast_agent/mcp/test_server_session_terminated.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Tests for server session termination handling and reconnection functionality. -""" - -from mcp.shared.exceptions import MCPError as McpError -from mcp_types import ErrorData - -from fast_agent.config import MCPServerSettings -from fast_agent.core.exceptions import FastAgentError, ServerSessionTerminatedError -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession - - -class TestServerSessionTerminatedError: - """Tests for the ServerSessionTerminatedError exception class.""" - - def test_error_code_constant(self): - """MCP SDK uses positive 32600 for session terminated.""" - assert ServerSessionTerminatedError.SESSION_TERMINATED_CODE == 32600 - - def test_error_creation(self): - """Exception captures server name and details.""" - error = ServerSessionTerminatedError(server_name="test-server", details="404") - assert error.server_name == "test-server" - assert "test-server" in str(error) - - def test_inherits_from_fast_agent_error(self): - """Exception inherits from FastAgentError.""" - assert isinstance(ServerSessionTerminatedError(server_name="x"), FastAgentError) - - -class TestSessionTerminationDetection: - """Tests for detecting session terminated errors.""" - - def _make_session(self): - session = object.__new__(MCPAgentClientSession) - session.session_server_name = "test" - return session - - def test_detects_mcp_error_code_32600(self): - """Detects McpError with code 32600.""" - error = McpError.from_error_data(ErrorData(code=32600, message="Session terminated")) - assert self._make_session()._is_session_terminated_error(error) is True - - def test_ignores_different_error_codes(self): - """Ignores McpError with different codes.""" - error = McpError.from_error_data(ErrorData(code=-32601, message="Method not found")) - assert self._make_session()._is_session_terminated_error(error) is False - - def test_ignores_non_mcp_errors(self): - """Ignores non-McpError exceptions.""" - session = self._make_session() - assert session._is_session_terminated_error(ValueError("test")) is False - assert session._is_session_terminated_error(ConnectionError("test")) is False - - -class TestReconnectConfig: - """Tests for reconnect_on_disconnect config option.""" - - def test_defaults_to_false(self): - """reconnect_on_disconnect defaults to False.""" - settings = MCPServerSettings(name="test", url="https://example.com/mcp") - assert settings.reconnect_on_disconnect is True - - def test_can_be_enabled(self): - """reconnect_on_disconnect can be set to True.""" - settings = MCPServerSettings( - name="test", url="https://example.com/mcp", reconnect_on_disconnect=True - ) - assert settings.reconnect_on_disconnect is True diff --git a/tests/unit/fast_agent/mcp/test_url_elicitation_required.py b/tests/unit/fast_agent/mcp/test_url_elicitation_required.py index 26256a55f..0c127065b 100644 --- a/tests/unit/fast_agent/mcp/test_url_elicitation_required.py +++ b/tests/unit/fast_agent/mcp/test_url_elicitation_required.py @@ -1,9 +1,5 @@ """Tests for URL elicitation required error handling helpers.""" -from mcp.shared.exceptions import MCPError as McpError -from mcp_types import URL_ELICITATION_REQUIRED, ErrorData - -from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from fast_agent.mcp.url_elicitation_required import parse_url_elicitation_required_data @@ -127,38 +123,3 @@ def test_rejects_blank_snake_case_elicitation_id(self) -> None: assert parsed.elicitations == [] assert len(parsed.issues) == 1 assert "error.data.elicitations[0] is invalid" in parsed.issues[0] - - -class TestUrlElicitationRequiredErrorDetection: - def _make_session(self) -> MCPAgentClientSession: - session = object.__new__(MCPAgentClientSession) - session.session_server_name = "test" - return session - - def test_detects_mcp_error_code_32042(self) -> None: - error = McpError.from_error_data( - ErrorData( - code=URL_ELICITATION_REQUIRED, - message="URL elicitation required", - data={ - "elicitations": [ - { - "mode": "url", - "message": "Authorize", - "url": "https://example.com/auth", - "elicitationId": "auth-1", - } - ] - }, - ) - ) - - assert self._make_session()._is_url_elicitation_required_error(error) is True - - def test_ignores_other_mcp_error_codes(self) -> None: - error = McpError.from_error_data(ErrorData(code=-32601, message="Method not found")) - - assert self._make_session()._is_url_elicitation_required_error(error) is False - - def test_ignores_non_mcp_exceptions(self) -> None: - assert self._make_session()._is_url_elicitation_required_error(ValueError("x")) is False From 0bec80058306658d0adf83495b17289e9d768bdb Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:35:32 +0100 Subject: [PATCH 3/3] Update contributor and structured output guidance --- AGENTS.md | 1 + docs/docs/guides/structured-outputs.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c77d3f87e..5f6bb5588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## fast-agent contributor notes - Use `uv run` for repo scripts and examples. +- Use the fast-agent CLI to start sub-agent tasks. Use gpt-5.6-terra?reasoning=high as the primary model for this. Escalate to the user if you want bigger or smaller models. - Always run `uv run scripts/lint.py` and `uv run scripts/typecheck.py` after code changes. - Check the type safety rules in `typesafe.md` and avoid hasattr/getattr style checks - Avoid unnecessary defensive programming. Validate at boundaries and use static analysis and type safety rules to write concise code. Do not caveat without good cause (external data processing for example). diff --git a/docs/docs/guides/structured-outputs.md b/docs/docs/guides/structured-outputs.md index 84237a245..6cb5f1ee1 100644 --- a/docs/docs/guides/structured-outputs.md +++ b/docs/docs/guides/structured-outputs.md @@ -320,7 +320,7 @@ The following models are recommended for single-pass Tools + Structured output. | xAI | `grok` | `grok-4.3` | `auto` | 10 | 0 | 0% | | Google | `gemini3flash` | `gemini-3-flash-preview` | `auto` | 10 | 0 | 0% | -`opus` currently resolves to Claude Opus 4.8 in fast-agent and should be validated with your own +`opus` currently resolves to Claude Opus 5 in fast-agent and should be validated with your own schemas before you promote it into a single-pass Tools + Structured policy. `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview` and `gemini-3.1-pro-preview` show elevated failure rates, so conduct your testing with your own schemas before finalizing a policy.