alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,2308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import unicodedata
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..runtime_kind import RuntimeKind, normalize_runtime_kind
|
|
15
|
+
from ..tools.registry import iter_builtin_tool_metadata
|
|
16
|
+
from .client import (
|
|
17
|
+
McpHttpClient,
|
|
18
|
+
McpListedResource,
|
|
19
|
+
McpListedTool,
|
|
20
|
+
McpStdioClient,
|
|
21
|
+
)
|
|
22
|
+
from .config import load_resolved_mcp_config, project_mcp_config_path, user_mcp_config_path
|
|
23
|
+
from .errors import McpConfigError as ConfigError
|
|
24
|
+
from .forge_scope import ForgeTaskMcpScope
|
|
25
|
+
from .models import ResolvedMcpConfig, ResolvedMcpServer
|
|
26
|
+
from .prompts import McpListedPrompt, prompts_mode_enabled
|
|
27
|
+
from .resources import resources_mode_enabled
|
|
28
|
+
from .untrusted_content import (
|
|
29
|
+
MCP_UNTRUSTED_TEXT_CHAR_LIMIT,
|
|
30
|
+
_looks_like_binary_blob,
|
|
31
|
+
build_host_owned_mcp_tool_description,
|
|
32
|
+
build_untrusted_mcp_text_block,
|
|
33
|
+
reduce_model_facing_tool_schema,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_LIVE_TOOL_RUNTIMES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
RuntimeKind.INTERACTIVE_CHAT,
|
|
39
|
+
RuntimeKind.ONE_SHOT,
|
|
40
|
+
RuntimeKind.FORGE_EXEC,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
_MAX_EXPOSED_MCP_TOOLS = 64
|
|
44
|
+
_MAX_SINGLE_TOOL_SCHEMA_BYTES = 32_000
|
|
45
|
+
_MAX_TOTAL_SCHEMA_BYTES = 120_000
|
|
46
|
+
_TOOL_ALIAS_MAX_LEN = 64
|
|
47
|
+
_MAX_STRUCTURED_CONTENT_BYTES = 24_000
|
|
48
|
+
_MAX_STRUCTURED_STRING_CHARS = 2_000
|
|
49
|
+
_MAX_STRUCTURED_CONTENT_DEPTH = 12
|
|
50
|
+
_MAX_LISTED_MCP_RESOURCES_TOTAL = 256
|
|
51
|
+
_MAX_LISTED_MCP_RESOURCES_PER_SERVER = 128
|
|
52
|
+
_DEFAULT_MCP_RESOURCE_LIST_LIMIT = 20
|
|
53
|
+
_MAX_MCP_RESOURCE_LIST_LIMIT = 50
|
|
54
|
+
_MAX_LISTED_MCP_PROMPTS_TOTAL = 256
|
|
55
|
+
_MAX_LISTED_MCP_PROMPTS_PER_SERVER = 128
|
|
56
|
+
_DEFAULT_MCP_PROMPT_LIST_LIMIT = 20
|
|
57
|
+
_MAX_MCP_PROMPT_LIST_LIMIT = 50
|
|
58
|
+
_ALIAS_CHARS_RE = re.compile(r"[^a-z0-9]+")
|
|
59
|
+
_McpLiveClient = McpStdioClient | McpHttpClient
|
|
60
|
+
_RESOURCE_TOOL_ALIASES = frozenset({"mcp_resources_list", "mcp_resource_read"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _McpClientLease:
|
|
64
|
+
"""Stable, session-owned indirection for one live MCP connection.
|
|
65
|
+
|
|
66
|
+
Tool definitions keep this lease for the lifetime of the agent session. A
|
|
67
|
+
lifecycle reconnect can therefore replace the underlying transport without
|
|
68
|
+
leaving model-facing tool callables pointed at a closed client. Disabling a
|
|
69
|
+
lease is fail-closed: every subsequent operation raises before any transport
|
|
70
|
+
call is made.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *, server_id: str, client: _McpLiveClient) -> None:
|
|
74
|
+
self.server_id = server_id
|
|
75
|
+
self._client: _McpLiveClient | None = client
|
|
76
|
+
self._enabled = True
|
|
77
|
+
self._generation = 1
|
|
78
|
+
self._lock = threading.RLock()
|
|
79
|
+
|
|
80
|
+
def _require_client(self) -> _McpLiveClient:
|
|
81
|
+
if not self._enabled:
|
|
82
|
+
raise RuntimeError(f"MCP server '{self.server_id}' is disabled for this session.")
|
|
83
|
+
client = self._client
|
|
84
|
+
if client is None or client.closed:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
f"MCP server '{self.server_id}' is disconnected for this session. "
|
|
87
|
+
"Reconnect it before retrying the operation."
|
|
88
|
+
)
|
|
89
|
+
return client
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def enabled(self) -> bool:
|
|
93
|
+
with self._lock:
|
|
94
|
+
return self._enabled
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def generation(self) -> int:
|
|
98
|
+
with self._lock:
|
|
99
|
+
return self._generation
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def closed(self) -> bool:
|
|
103
|
+
with self._lock:
|
|
104
|
+
client = self._client
|
|
105
|
+
return client is None or client.closed
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def connected(self) -> bool:
|
|
109
|
+
with self._lock:
|
|
110
|
+
if not self._enabled:
|
|
111
|
+
return False
|
|
112
|
+
client = self._client
|
|
113
|
+
if client is None or client.closed:
|
|
114
|
+
return False
|
|
115
|
+
transport = client.transport
|
|
116
|
+
process = getattr(transport, "process", None)
|
|
117
|
+
if process is not None and process.poll() is not None:
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def transport(self) -> Any:
|
|
123
|
+
"""Compatibility-only access used by low-level transport diagnostics/tests."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
return self._require_client().transport
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def tools_list_changed(self) -> bool:
|
|
129
|
+
with self._lock:
|
|
130
|
+
return self._require_client().tools_list_changed
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def resources_list_changed(self) -> bool:
|
|
134
|
+
with self._lock:
|
|
135
|
+
return self._require_client().resources_list_changed
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def prompts_list_changed(self) -> bool:
|
|
139
|
+
with self._lock:
|
|
140
|
+
return self._require_client().prompts_list_changed
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def session_negotiated(self) -> bool:
|
|
144
|
+
with self._lock:
|
|
145
|
+
return self._require_client().session_negotiated
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def roots_capability_enabled(self) -> bool:
|
|
149
|
+
with self._lock:
|
|
150
|
+
return self._require_client().roots_capability_enabled
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def supports_tools(self) -> bool:
|
|
154
|
+
with self._lock:
|
|
155
|
+
return self._require_client().supports_tools
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def supports_resources(self) -> bool:
|
|
159
|
+
with self._lock:
|
|
160
|
+
return self._require_client().supports_resources
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def supports_prompts(self) -> bool:
|
|
164
|
+
with self._lock:
|
|
165
|
+
return self._require_client().supports_prompts
|
|
166
|
+
|
|
167
|
+
def ensure_initialized(self) -> None:
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._require_client().ensure_initialized()
|
|
170
|
+
|
|
171
|
+
def observe_notifications(self) -> None:
|
|
172
|
+
with self._lock:
|
|
173
|
+
self._require_client().observe_notifications()
|
|
174
|
+
|
|
175
|
+
def list_tools(self) -> tuple[McpListedTool, ...]:
|
|
176
|
+
with self._lock:
|
|
177
|
+
return self._require_client().list_tools()
|
|
178
|
+
|
|
179
|
+
def list_resources(self) -> tuple[McpListedResource, ...]:
|
|
180
|
+
with self._lock:
|
|
181
|
+
return self._require_client().list_resources()
|
|
182
|
+
|
|
183
|
+
def list_prompts(self) -> tuple[McpListedPrompt, ...]:
|
|
184
|
+
with self._lock:
|
|
185
|
+
return self._require_client().list_prompts()
|
|
186
|
+
|
|
187
|
+
def read_resource(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
resource_uri: str,
|
|
191
|
+
allowed_uris: Any = None,
|
|
192
|
+
) -> Any:
|
|
193
|
+
with self._lock:
|
|
194
|
+
return self._require_client().read_resource(
|
|
195
|
+
resource_uri=resource_uri,
|
|
196
|
+
allowed_uris=allowed_uris,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def get_prompt(self, *, name: str, arguments: dict[str, str] | None = None) -> Any:
|
|
200
|
+
with self._lock:
|
|
201
|
+
return self._require_client().get_prompt(name=name, arguments=arguments)
|
|
202
|
+
|
|
203
|
+
def call_tool(self, *, tool_name: str, arguments: dict[str, Any]) -> Any:
|
|
204
|
+
with self._lock:
|
|
205
|
+
return self._require_client().call_tool(tool_name=tool_name, arguments=arguments)
|
|
206
|
+
|
|
207
|
+
def disable(self) -> bool:
|
|
208
|
+
with self._lock:
|
|
209
|
+
changed = self._enabled or self._client is not None
|
|
210
|
+
self._enabled = False
|
|
211
|
+
client = self._client
|
|
212
|
+
self._client = None
|
|
213
|
+
if client is not None:
|
|
214
|
+
client.close()
|
|
215
|
+
return changed
|
|
216
|
+
|
|
217
|
+
def replace_client(self, client: _McpLiveClient) -> None:
|
|
218
|
+
with self._lock:
|
|
219
|
+
old_client = self._client
|
|
220
|
+
self._client = client
|
|
221
|
+
self._enabled = True
|
|
222
|
+
self._generation += 1
|
|
223
|
+
if old_client is not None and old_client is not client:
|
|
224
|
+
old_client.close()
|
|
225
|
+
|
|
226
|
+
def close(self) -> None:
|
|
227
|
+
self.disable()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_McpSessionClient = _McpLiveClient | _McpClientLease
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sanitize_alias_part(value: str, *, fallback: str) -> str:
|
|
234
|
+
lowered = str(value or "").strip().lower()
|
|
235
|
+
ascii_value = unicodedata.normalize("NFKD", lowered).encode("ascii", "ignore").decode("ascii")
|
|
236
|
+
sanitized = _ALIAS_CHARS_RE.sub("_", ascii_value).strip("_")
|
|
237
|
+
sanitized = re.sub(r"_+", "_", sanitized)
|
|
238
|
+
return sanitized or fallback
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _normalize_tool_name_key(value: str) -> str:
|
|
242
|
+
return str(value).casefold()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _mcp_runtime_enabled(runtime_kind: RuntimeKind) -> bool:
|
|
246
|
+
return runtime_kind in _LIVE_TOOL_RUNTIMES
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _server_namespace(server: ResolvedMcpServer) -> str:
|
|
250
|
+
if server.tool_prefix:
|
|
251
|
+
return _sanitize_alias_part(server.tool_prefix, fallback="server")
|
|
252
|
+
return _sanitize_alias_part(server.id, fallback="server")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _tool_base_alias(server: ResolvedMcpServer, tool_name: str) -> str:
|
|
256
|
+
return f"mcp__{_server_namespace(server)}__{_sanitize_alias_part(tool_name, fallback='tool')}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _hashed_alias(base_alias: str, *, unique_key: str) -> str:
|
|
260
|
+
digest = hashlib.sha1(unique_key.encode("utf-8")).hexdigest()[:10]
|
|
261
|
+
suffix = f"__{digest}"
|
|
262
|
+
prefix_len = max(1, _TOOL_ALIAS_MAX_LEN - len(suffix))
|
|
263
|
+
prefix = base_alias[:prefix_len].rstrip("_")
|
|
264
|
+
if not prefix:
|
|
265
|
+
prefix = "mcp"
|
|
266
|
+
return f"{prefix}{suffix}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _stable_json_size(value: object) -> int:
|
|
270
|
+
return len(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _omitted_structured_value(reason: str, *, detail: str | None = None) -> dict[str, Any]:
|
|
274
|
+
payload: dict[str, Any] = {
|
|
275
|
+
"omitted": True,
|
|
276
|
+
"reason": reason,
|
|
277
|
+
}
|
|
278
|
+
if detail:
|
|
279
|
+
payload["summary"] = detail
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _sanitize_structured_content_value(value: Any, *, depth: int = 0) -> Any:
|
|
284
|
+
if value is None or isinstance(value, (bool, int)):
|
|
285
|
+
return value
|
|
286
|
+
if isinstance(value, float):
|
|
287
|
+
if math.isfinite(value):
|
|
288
|
+
return value
|
|
289
|
+
return _omitted_structured_value("non_finite_number", detail="non-finite number omitted")
|
|
290
|
+
if isinstance(value, str):
|
|
291
|
+
if _looks_like_binary_blob(value):
|
|
292
|
+
return _omitted_structured_value(
|
|
293
|
+
"binary_like_string",
|
|
294
|
+
detail=f"binary-like string omitted ({len(value)} chars)",
|
|
295
|
+
)
|
|
296
|
+
if len(value) > _MAX_STRUCTURED_STRING_CHARS:
|
|
297
|
+
return _omitted_structured_value(
|
|
298
|
+
"string_too_large",
|
|
299
|
+
detail=f"long string omitted ({len(value)} chars)",
|
|
300
|
+
)
|
|
301
|
+
return value
|
|
302
|
+
if depth >= _MAX_STRUCTURED_CONTENT_DEPTH:
|
|
303
|
+
return _omitted_structured_value(
|
|
304
|
+
"max_depth_exceeded",
|
|
305
|
+
detail="nested structured content omitted",
|
|
306
|
+
)
|
|
307
|
+
if isinstance(value, list):
|
|
308
|
+
return [_sanitize_structured_content_value(item, depth=depth + 1) for item in value]
|
|
309
|
+
if isinstance(value, dict):
|
|
310
|
+
sanitized: dict[str, Any] = {}
|
|
311
|
+
for key, item in value.items():
|
|
312
|
+
sanitized[str(key)] = _sanitize_structured_content_value(item, depth=depth + 1)
|
|
313
|
+
return sanitized
|
|
314
|
+
return _omitted_structured_value(
|
|
315
|
+
"unsupported_type",
|
|
316
|
+
detail=f"{type(value).__name__} omitted",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _normalize_prompt_structured_content(value: Any) -> Any:
|
|
321
|
+
sanitized = _sanitize_structured_content_value(copy.deepcopy(value))
|
|
322
|
+
try:
|
|
323
|
+
content_bytes = _stable_json_size(sanitized)
|
|
324
|
+
except TypeError:
|
|
325
|
+
return _omitted_structured_value(
|
|
326
|
+
"non_serializable",
|
|
327
|
+
detail="non-serializable structured content omitted",
|
|
328
|
+
)
|
|
329
|
+
if content_bytes > _MAX_STRUCTURED_CONTENT_BYTES:
|
|
330
|
+
return _omitted_structured_value(
|
|
331
|
+
"structured_content_too_large",
|
|
332
|
+
detail=f"structured content omitted ({content_bytes} bytes)",
|
|
333
|
+
)
|
|
334
|
+
return sanitized
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _validate_function_tool_schema(
|
|
338
|
+
*,
|
|
339
|
+
server: ResolvedMcpServer,
|
|
340
|
+
tool: McpListedTool,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
raw_schema = copy.deepcopy(tool.input_schema)
|
|
343
|
+
if raw_schema.get("type") != "object":
|
|
344
|
+
raise ConfigError(
|
|
345
|
+
f"MCP server '{server.id}' tool '{tool.name}' has incompatible inputSchema: "
|
|
346
|
+
"root type must be 'object'."
|
|
347
|
+
)
|
|
348
|
+
properties = raw_schema.get("properties", {})
|
|
349
|
+
if not isinstance(properties, dict):
|
|
350
|
+
raise ConfigError(
|
|
351
|
+
f"MCP server '{server.id}' tool '{tool.name}' has incompatible inputSchema: "
|
|
352
|
+
"'properties' must be an object."
|
|
353
|
+
)
|
|
354
|
+
required = raw_schema.get("required", [])
|
|
355
|
+
if not isinstance(required, list) or any(not isinstance(item, str) for item in required):
|
|
356
|
+
raise ConfigError(
|
|
357
|
+
f"MCP server '{server.id}' tool '{tool.name}' has incompatible inputSchema: "
|
|
358
|
+
"'required' must be an array of strings."
|
|
359
|
+
)
|
|
360
|
+
schema = reduce_model_facing_tool_schema(raw_schema)
|
|
361
|
+
try:
|
|
362
|
+
schema_bytes = _stable_json_size(schema)
|
|
363
|
+
except TypeError as exc:
|
|
364
|
+
raise ConfigError(
|
|
365
|
+
f"MCP server '{server.id}' tool '{tool.name}' has non-serializable inputSchema."
|
|
366
|
+
) from exc
|
|
367
|
+
if schema_bytes > _MAX_SINGLE_TOOL_SCHEMA_BYTES:
|
|
368
|
+
raise ConfigError(
|
|
369
|
+
f"MCP server '{server.id}' tool '{tool.name}' exposes an inputSchema that is too "
|
|
370
|
+
f"large ({schema_bytes} bytes). Narrow exposure with allowed_tools."
|
|
371
|
+
)
|
|
372
|
+
return schema
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _wrap_tool_result_text(
|
|
376
|
+
*,
|
|
377
|
+
server_id: str,
|
|
378
|
+
tool_name: str,
|
|
379
|
+
text: str,
|
|
380
|
+
mime_type: str | None = None,
|
|
381
|
+
) -> str:
|
|
382
|
+
original_char_count = len(text)
|
|
383
|
+
truncated = False
|
|
384
|
+
wrapped_text = text
|
|
385
|
+
if _looks_like_binary_blob(text):
|
|
386
|
+
wrapped_text = "(omitted: binary-like text)"
|
|
387
|
+
elif len(text) > MCP_UNTRUSTED_TEXT_CHAR_LIMIT:
|
|
388
|
+
wrapped_text = text[:MCP_UNTRUSTED_TEXT_CHAR_LIMIT]
|
|
389
|
+
truncated = True
|
|
390
|
+
return build_untrusted_mcp_text_block(
|
|
391
|
+
source_type="tool_result",
|
|
392
|
+
server_id=server_id,
|
|
393
|
+
source_name=tool_name,
|
|
394
|
+
text=wrapped_text,
|
|
395
|
+
mime_type=mime_type,
|
|
396
|
+
original_char_count=original_char_count,
|
|
397
|
+
truncated=truncated,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass(frozen=True)
|
|
402
|
+
class McpToolBinding:
|
|
403
|
+
server_id: str
|
|
404
|
+
tool_name: str
|
|
405
|
+
tool_alias: str
|
|
406
|
+
description: str
|
|
407
|
+
parameters: dict[str, Any]
|
|
408
|
+
client: _McpSessionClient = field(repr=False)
|
|
409
|
+
session_mode: str | None = None
|
|
410
|
+
|
|
411
|
+
def bind_session_mode(self, session_mode: str | None) -> McpToolBinding:
|
|
412
|
+
normalized = str(session_mode or "").strip().lower() or None
|
|
413
|
+
return replace(self, session_mode=normalized)
|
|
414
|
+
|
|
415
|
+
def run(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
416
|
+
if self.session_mode == "readonly":
|
|
417
|
+
raise RuntimeError(
|
|
418
|
+
f"Blocked in readonly mode: MCP tool '{self.tool_alias}' is not available."
|
|
419
|
+
)
|
|
420
|
+
result = self.client.call_tool(
|
|
421
|
+
tool_name=self.tool_name,
|
|
422
|
+
arguments=arguments,
|
|
423
|
+
)
|
|
424
|
+
content_items: list[dict[str, Any]] = []
|
|
425
|
+
for item in result.content:
|
|
426
|
+
payload_item = copy.deepcopy(item)
|
|
427
|
+
item_text = payload_item.get("text")
|
|
428
|
+
mime_type = payload_item.get("mime_type")
|
|
429
|
+
if isinstance(item_text, str):
|
|
430
|
+
payload_item["text"] = _wrap_tool_result_text(
|
|
431
|
+
server_id=self.server_id,
|
|
432
|
+
tool_name=self.tool_name,
|
|
433
|
+
text=item_text,
|
|
434
|
+
mime_type=mime_type if isinstance(mime_type, str) else None,
|
|
435
|
+
)
|
|
436
|
+
content_items.append(payload_item)
|
|
437
|
+
payload: dict[str, Any] = {
|
|
438
|
+
"server_id": self.server_id,
|
|
439
|
+
"tool_alias": self.tool_alias,
|
|
440
|
+
"tool_name": self.tool_name,
|
|
441
|
+
"is_error": result.is_error,
|
|
442
|
+
"content": content_items,
|
|
443
|
+
"content_summary": result.content_summary,
|
|
444
|
+
}
|
|
445
|
+
if result.structured_content is not None:
|
|
446
|
+
payload["structured_content"] = _normalize_prompt_structured_content(
|
|
447
|
+
result.structured_content
|
|
448
|
+
)
|
|
449
|
+
if result.extracted_text:
|
|
450
|
+
payload["text"] = _wrap_tool_result_text(
|
|
451
|
+
server_id=self.server_id,
|
|
452
|
+
tool_name=self.tool_name,
|
|
453
|
+
text=result.extracted_text,
|
|
454
|
+
)
|
|
455
|
+
return payload
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass(frozen=True)
|
|
459
|
+
class McpHostToolBinding:
|
|
460
|
+
tool_name: str
|
|
461
|
+
tool_alias: str
|
|
462
|
+
description: str
|
|
463
|
+
parameters: dict[str, Any]
|
|
464
|
+
run_handler: Any = field(repr=False)
|
|
465
|
+
session_mode: str | None = None
|
|
466
|
+
|
|
467
|
+
def bind_session_mode(self, session_mode: str | None) -> McpHostToolBinding:
|
|
468
|
+
normalized = str(session_mode or "").strip().lower() or None
|
|
469
|
+
return replace(self, session_mode=normalized)
|
|
470
|
+
|
|
471
|
+
def run(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
472
|
+
if self.session_mode == "readonly":
|
|
473
|
+
raise RuntimeError(
|
|
474
|
+
f"Blocked in readonly mode: MCP tool '{self.tool_alias}' is not available."
|
|
475
|
+
)
|
|
476
|
+
return self.run_handler(arguments)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@dataclass(frozen=True)
|
|
480
|
+
class _CollectedServerCatalog:
|
|
481
|
+
server: ResolvedMcpServer
|
|
482
|
+
client: _McpSessionClient
|
|
483
|
+
raw_tools: tuple[McpListedTool, ...]
|
|
484
|
+
filtered_tools: tuple[McpListedTool, ...]
|
|
485
|
+
raw_tool_names: tuple[str, ...]
|
|
486
|
+
listed_resources: tuple[McpListedResource, ...]
|
|
487
|
+
resources_snapshot_loaded: bool
|
|
488
|
+
tools_list_changed: bool
|
|
489
|
+
resources_list_changed: bool
|
|
490
|
+
roots_capability_enabled: bool
|
|
491
|
+
resources_capability_advertised: bool
|
|
492
|
+
session_negotiated: bool
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@dataclass(frozen=True)
|
|
496
|
+
class _FlatToolRecord:
|
|
497
|
+
server: ResolvedMcpServer
|
|
498
|
+
client: _McpSessionClient
|
|
499
|
+
tool: McpListedTool
|
|
500
|
+
parameters: dict[str, Any]
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def unique_key(self) -> str:
|
|
504
|
+
return f"{self.server.id}\x00{self.tool.name}"
|
|
505
|
+
|
|
506
|
+
@property
|
|
507
|
+
def base_alias(self) -> str:
|
|
508
|
+
return _tool_base_alias(self.server, self.tool.name)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@dataclass(frozen=True)
|
|
512
|
+
class _SnapshottedResourceRecord:
|
|
513
|
+
server: ResolvedMcpServer
|
|
514
|
+
client: _McpSessionClient
|
|
515
|
+
resource: McpListedResource
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def lookup_key(self) -> tuple[str, str]:
|
|
519
|
+
return (self.server.id, self.resource.uri)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@dataclass(frozen=True)
|
|
523
|
+
class _SnapshottedPromptRecord:
|
|
524
|
+
server: ResolvedMcpServer
|
|
525
|
+
client: _McpSessionClient
|
|
526
|
+
prompt: McpListedPrompt
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def lookup_key(self) -> tuple[str, str]:
|
|
530
|
+
return (self.server.id, self.prompt.name)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
_McpManagerToolBinding = McpToolBinding | McpHostToolBinding
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class McpManager:
|
|
537
|
+
def __init__(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
resolved_config: ResolvedMcpConfig,
|
|
541
|
+
workspace_root: Path,
|
|
542
|
+
runtime_kind: RuntimeKind | str,
|
|
543
|
+
session_id: str | None = None,
|
|
544
|
+
) -> None:
|
|
545
|
+
self.resolved_config = resolved_config
|
|
546
|
+
self.workspace_root = workspace_root.resolve()
|
|
547
|
+
self.runtime_kind = normalize_runtime_kind(runtime_kind)
|
|
548
|
+
self.session_id = str(session_id or "").strip() or None
|
|
549
|
+
self._resolved_servers = tuple(resolved_config.servers)
|
|
550
|
+
self._active_servers = resolved_config.active_servers_for(self.runtime_kind)
|
|
551
|
+
self._resolved_servers_by_id = {server.id: server for server in self._resolved_servers}
|
|
552
|
+
self._active_servers_by_id = {server.id: server for server in self._active_servers}
|
|
553
|
+
self._live_runtime_enabled = _mcp_runtime_enabled(self.runtime_kind)
|
|
554
|
+
self._closed = False
|
|
555
|
+
self._lifecycle_lock = threading.RLock()
|
|
556
|
+
self._disabled_server_ids: set[str] = set()
|
|
557
|
+
self._client_leases_by_server_id: dict[str, list[_McpClientLease]] = {}
|
|
558
|
+
self._snapshot_loaded = False
|
|
559
|
+
self._snapshot_error: BaseException | None = None
|
|
560
|
+
self._tool_bindings: tuple[_McpManagerToolBinding, ...] = ()
|
|
561
|
+
self._resource_records: tuple[_SnapshottedResourceRecord, ...] = ()
|
|
562
|
+
self._resource_lookup: dict[tuple[str, str], _SnapshottedResourceRecord] = {}
|
|
563
|
+
self._resource_snapshotted_server_ids: set[str] = set()
|
|
564
|
+
self._prompt_enabled_servers = tuple(
|
|
565
|
+
server
|
|
566
|
+
for server in self._active_servers
|
|
567
|
+
if prompts_mode_enabled(
|
|
568
|
+
prompts_mode=server.prompts_mode,
|
|
569
|
+
runtime_kind=self.runtime_kind,
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
self._prompt_enabled_servers_by_id = {
|
|
573
|
+
server.id: server for server in self._prompt_enabled_servers
|
|
574
|
+
}
|
|
575
|
+
self._prompt_snapshot_loaded = not self._prompt_enabled_servers
|
|
576
|
+
self._prompt_loaded_server_ids: set[str] = set()
|
|
577
|
+
self._prompt_server_errors: dict[str, BaseException] = {}
|
|
578
|
+
self._prompt_records: tuple[_SnapshottedPromptRecord, ...] = ()
|
|
579
|
+
self._prompt_records_by_server_id: dict[str, tuple[_SnapshottedPromptRecord, ...]] = {}
|
|
580
|
+
self._prompt_lookup: dict[tuple[str, str], _SnapshottedPromptRecord] = {}
|
|
581
|
+
self._tool_stale_server_ids: set[str] = set()
|
|
582
|
+
self._resource_stale_server_ids: set[str] = set()
|
|
583
|
+
self._prompt_stale_server_ids: set[str] = set()
|
|
584
|
+
self._clients_by_server_id: dict[str, _McpSessionClient] = {}
|
|
585
|
+
self._prompt_clients_by_server_id: dict[str, _McpSessionClient] = {}
|
|
586
|
+
self._prompt_server_catalogs_by_id = {
|
|
587
|
+
server.id: self._initial_prompt_server_catalog(server)
|
|
588
|
+
for server in self._prompt_enabled_servers
|
|
589
|
+
}
|
|
590
|
+
self._catalog_snapshot: dict[str, Any] = {
|
|
591
|
+
"catalog_initialized": False,
|
|
592
|
+
"live_tool_runtime_enabled": self._live_runtime_enabled,
|
|
593
|
+
"active_server_ids": [server.id for server in self._active_servers],
|
|
594
|
+
"server_catalogs": [],
|
|
595
|
+
"exposed_tool_aliases": [],
|
|
596
|
+
"exposed_tool_names": [],
|
|
597
|
+
"exposed_tool_count": 0,
|
|
598
|
+
"snapshotted_resource_count": 0,
|
|
599
|
+
"resource_tool_names": [],
|
|
600
|
+
"resource_tool_count": 0,
|
|
601
|
+
"prompt_enabled_server_ids": [server.id for server in self._prompt_enabled_servers],
|
|
602
|
+
"prompt_snapshotted_server_ids": [],
|
|
603
|
+
"prompt_snapshot_complete": self._prompt_snapshot_loaded,
|
|
604
|
+
"prompt_snapshot_partial": False,
|
|
605
|
+
"tool_stale_server_ids": [],
|
|
606
|
+
"resource_stale_server_ids": [],
|
|
607
|
+
"prompt_stale_server_ids": [],
|
|
608
|
+
"prompt_server_catalogs": [],
|
|
609
|
+
"snapshotted_prompt_count": 0,
|
|
610
|
+
"manual_prompt_surface_enabled": False,
|
|
611
|
+
}
|
|
612
|
+
self._sync_prompt_catalog_snapshot_metadata()
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def resolved_servers(self) -> tuple[ResolvedMcpServer, ...]:
|
|
616
|
+
return self._resolved_servers
|
|
617
|
+
|
|
618
|
+
@property
|
|
619
|
+
def active_servers(self) -> tuple[ResolvedMcpServer, ...]:
|
|
620
|
+
return self._active_servers
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def tool_bindings(self) -> tuple[_McpManagerToolBinding, ...]:
|
|
624
|
+
self._ensure_tool_snapshot()
|
|
625
|
+
return self._tool_bindings
|
|
626
|
+
|
|
627
|
+
@property
|
|
628
|
+
def closed(self) -> bool:
|
|
629
|
+
return self._closed
|
|
630
|
+
|
|
631
|
+
def startup_metadata(self) -> dict[str, Any]:
|
|
632
|
+
return {
|
|
633
|
+
"config_present": self.resolved_config.has_any_config,
|
|
634
|
+
"user_config_present": self.resolved_config.user_config_present,
|
|
635
|
+
"project_config_present": self.resolved_config.project_config_present,
|
|
636
|
+
"resolved_server_count": len(self._resolved_servers),
|
|
637
|
+
"resolved_server_ids": [server.id for server in self._resolved_servers],
|
|
638
|
+
"active_server_count": len(self._active_servers),
|
|
639
|
+
"active_server_ids": [server.id for server in self._active_servers],
|
|
640
|
+
"live_tool_runtime_enabled": self._live_runtime_enabled,
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
def _register_client_lease(
|
|
644
|
+
self,
|
|
645
|
+
*,
|
|
646
|
+
server_id: str,
|
|
647
|
+
client: _McpLiveClient,
|
|
648
|
+
) -> _McpClientLease:
|
|
649
|
+
if self._client_leases_by_server_id.get(server_id):
|
|
650
|
+
raise RuntimeError(
|
|
651
|
+
f"MCP server '{server_id}' already has a session-owned client lease."
|
|
652
|
+
)
|
|
653
|
+
lease = _McpClientLease(server_id=server_id, client=client)
|
|
654
|
+
self._client_leases_by_server_id[server_id] = [lease]
|
|
655
|
+
return lease
|
|
656
|
+
|
|
657
|
+
def _server_lifecycle_target(self, server_id: str) -> ResolvedMcpServer:
|
|
658
|
+
normalized_server_id = str(server_id or "").strip()
|
|
659
|
+
if not normalized_server_id:
|
|
660
|
+
raise RuntimeError("server_id must be a non-empty string.")
|
|
661
|
+
if self._closed:
|
|
662
|
+
raise RuntimeError("MCP manager is closed.")
|
|
663
|
+
server = self._active_servers_by_id.get(normalized_server_id)
|
|
664
|
+
if server is not None:
|
|
665
|
+
if server.trust != "explicit":
|
|
666
|
+
raise RuntimeError(
|
|
667
|
+
f"MCP server '{normalized_server_id}' is not explicitly trusted."
|
|
668
|
+
)
|
|
669
|
+
if not self._live_runtime_enabled:
|
|
670
|
+
raise RuntimeError(
|
|
671
|
+
f"MCP server lifecycle is unavailable for runtime '{self.runtime_kind.value}'."
|
|
672
|
+
)
|
|
673
|
+
return server
|
|
674
|
+
if normalized_server_id in self._resolved_servers_by_id:
|
|
675
|
+
raise RuntimeError(
|
|
676
|
+
f"MCP server '{normalized_server_id}' is not active for runtime "
|
|
677
|
+
f"'{self.runtime_kind.value}'."
|
|
678
|
+
)
|
|
679
|
+
raise RuntimeError(f"Unknown MCP server '{normalized_server_id}'.")
|
|
680
|
+
|
|
681
|
+
def server_lifecycle_status(self, *, server_id: str) -> dict[str, Any]:
|
|
682
|
+
with self._lifecycle_lock:
|
|
683
|
+
server = self._server_lifecycle_target(server_id)
|
|
684
|
+
leases = tuple(self._client_leases_by_server_id.get(server.id, ()))
|
|
685
|
+
enabled = server.id not in self._disabled_server_ids
|
|
686
|
+
connected = bool(enabled and any(lease.connected for lease in leases))
|
|
687
|
+
if not enabled:
|
|
688
|
+
connection_state = "disabled"
|
|
689
|
+
elif connected:
|
|
690
|
+
connection_state = "connected"
|
|
691
|
+
elif leases:
|
|
692
|
+
connection_state = "disconnected"
|
|
693
|
+
else:
|
|
694
|
+
connection_state = "not_materialized"
|
|
695
|
+
server_bindings = [
|
|
696
|
+
binding
|
|
697
|
+
for binding in self._tool_bindings
|
|
698
|
+
if isinstance(binding, McpToolBinding) and binding.server_id == server.id
|
|
699
|
+
]
|
|
700
|
+
resource_count = sum(
|
|
701
|
+
1 for record in self._resource_records if record.server.id == server.id
|
|
702
|
+
)
|
|
703
|
+
prompt_count = len(self._prompt_records_by_server_id.get(server.id, ()))
|
|
704
|
+
return {
|
|
705
|
+
"session_id": self.session_id,
|
|
706
|
+
"server_id": server.id,
|
|
707
|
+
"transport": server.transport,
|
|
708
|
+
"enabled": enabled,
|
|
709
|
+
"connection_state": connection_state,
|
|
710
|
+
"connected": connected,
|
|
711
|
+
"generation": max((lease.generation for lease in leases), default=0),
|
|
712
|
+
"catalog_initialized": self._snapshot_loaded,
|
|
713
|
+
"exposed_tool_count": len(server_bindings),
|
|
714
|
+
"snapshotted_resource_count": resource_count,
|
|
715
|
+
"prompt_snapshot_loaded": server.id in self._prompt_loaded_server_ids,
|
|
716
|
+
"snapshotted_prompt_count": prompt_count,
|
|
717
|
+
"secret_values_included": False,
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
def disable_server(self, *, server_id: str) -> dict[str, Any]:
|
|
721
|
+
with self._lifecycle_lock:
|
|
722
|
+
server = self._server_lifecycle_target(server_id)
|
|
723
|
+
already_disabled = server.id in self._disabled_server_ids
|
|
724
|
+
self._disabled_server_ids.add(server.id)
|
|
725
|
+
changed = not already_disabled
|
|
726
|
+
for lease in self._client_leases_by_server_id.get(server.id, ()):
|
|
727
|
+
changed = lease.disable() or changed
|
|
728
|
+
return {
|
|
729
|
+
**self.server_lifecycle_status(server_id=server.id),
|
|
730
|
+
"changed": changed,
|
|
731
|
+
"action": "disable",
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
def _replacement_tool_signature(
|
|
735
|
+
self,
|
|
736
|
+
*,
|
|
737
|
+
server: ResolvedMcpServer,
|
|
738
|
+
client: _McpLiveClient,
|
|
739
|
+
) -> list[tuple[str, str, dict[str, Any]]]:
|
|
740
|
+
if not client.supports_tools:
|
|
741
|
+
if server.allowed_tools or server.denied_tools:
|
|
742
|
+
raise RuntimeError(
|
|
743
|
+
f"MCP server '{server.id}' no longer advertises its configured tools."
|
|
744
|
+
)
|
|
745
|
+
return []
|
|
746
|
+
raw_tools = client.list_tools()
|
|
747
|
+
filtered_tools, _raw_names = self._filter_server_tools(
|
|
748
|
+
server=server,
|
|
749
|
+
raw_tools=raw_tools,
|
|
750
|
+
)
|
|
751
|
+
return [
|
|
752
|
+
(
|
|
753
|
+
tool.name,
|
|
754
|
+
build_host_owned_mcp_tool_description(
|
|
755
|
+
server_id=server.id,
|
|
756
|
+
tool_name=tool.name,
|
|
757
|
+
server_description=tool.description,
|
|
758
|
+
),
|
|
759
|
+
_validate_function_tool_schema(server=server, tool=tool),
|
|
760
|
+
)
|
|
761
|
+
for tool in filtered_tools
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
def _validate_replacement_client(
|
|
765
|
+
self,
|
|
766
|
+
*,
|
|
767
|
+
server: ResolvedMcpServer,
|
|
768
|
+
client: _McpLiveClient,
|
|
769
|
+
validate_prompts: bool = True,
|
|
770
|
+
) -> None:
|
|
771
|
+
client.ensure_initialized()
|
|
772
|
+
if self._snapshot_loaded:
|
|
773
|
+
expected_tools = [
|
|
774
|
+
(binding.tool_name, binding.description, binding.parameters)
|
|
775
|
+
for binding in self._tool_bindings
|
|
776
|
+
if isinstance(binding, McpToolBinding) and binding.server_id == server.id
|
|
777
|
+
]
|
|
778
|
+
replacement_tools = self._replacement_tool_signature(server=server, client=client)
|
|
779
|
+
if replacement_tools != expected_tools:
|
|
780
|
+
raise RuntimeError(
|
|
781
|
+
f"MCP server '{server.id}' tool catalog changed. Recreate the IDE session "
|
|
782
|
+
"before using the new catalog."
|
|
783
|
+
)
|
|
784
|
+
if server.id in self._resource_snapshotted_server_ids:
|
|
785
|
+
replacement_resources = (
|
|
786
|
+
list(client.list_resources()) if client.supports_resources else []
|
|
787
|
+
)
|
|
788
|
+
expected_resources = [
|
|
789
|
+
record.resource
|
|
790
|
+
for record in self._resource_records
|
|
791
|
+
if record.server.id == server.id
|
|
792
|
+
]
|
|
793
|
+
if replacement_resources != expected_resources:
|
|
794
|
+
raise RuntimeError(
|
|
795
|
+
f"MCP server '{server.id}' resource catalog changed. Recreate the IDE "
|
|
796
|
+
"session before using the new catalog."
|
|
797
|
+
)
|
|
798
|
+
if validate_prompts and server.id in self._prompt_loaded_server_ids:
|
|
799
|
+
replacement_prompts = list(client.list_prompts()) if client.supports_prompts else []
|
|
800
|
+
expected_prompts = [
|
|
801
|
+
record.prompt for record in self._prompt_records_by_server_id.get(server.id, ())
|
|
802
|
+
]
|
|
803
|
+
if replacement_prompts != expected_prompts:
|
|
804
|
+
raise RuntimeError(
|
|
805
|
+
f"MCP server '{server.id}' prompt catalog changed. Recreate the IDE session "
|
|
806
|
+
"before using the new catalog."
|
|
807
|
+
)
|
|
808
|
+
# A list_changed notification that races any comparison means the
|
|
809
|
+
# replacement was already stale before it could become session-owned.
|
|
810
|
+
# Never swap such a connection under frozen model-facing bindings.
|
|
811
|
+
client.observe_notifications()
|
|
812
|
+
changed_surfaces: list[str] = []
|
|
813
|
+
if self._snapshot_loaded and client.tools_list_changed:
|
|
814
|
+
changed_surfaces.append("tool")
|
|
815
|
+
if server.id in self._resource_snapshotted_server_ids and client.resources_list_changed:
|
|
816
|
+
changed_surfaces.append("resource")
|
|
817
|
+
if (
|
|
818
|
+
validate_prompts
|
|
819
|
+
and server.id in self._prompt_loaded_server_ids
|
|
820
|
+
and client.prompts_list_changed
|
|
821
|
+
):
|
|
822
|
+
changed_surfaces.append("prompt")
|
|
823
|
+
if changed_surfaces:
|
|
824
|
+
surfaces = ", ".join(changed_surfaces)
|
|
825
|
+
raise RuntimeError(
|
|
826
|
+
f"MCP server '{server.id}' reported a {surfaces} catalog change while "
|
|
827
|
+
"reconnecting. Recreate the IDE session before using the new catalog."
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
def _connect_server(self, *, server_id: str, action: str) -> dict[str, Any]:
|
|
831
|
+
with self._lifecycle_lock:
|
|
832
|
+
server = self._server_lifecycle_target(server_id)
|
|
833
|
+
if action == "enable" and server.id not in self._disabled_server_ids:
|
|
834
|
+
status = self.server_lifecycle_status(server_id=server.id)
|
|
835
|
+
if status["connected"]:
|
|
836
|
+
return {**status, "changed": False, "action": action}
|
|
837
|
+
client = self._build_live_client(server)
|
|
838
|
+
try:
|
|
839
|
+
self._validate_replacement_client(server=server, client=client)
|
|
840
|
+
leases = self._client_leases_by_server_id.get(server.id, [])
|
|
841
|
+
if leases:
|
|
842
|
+
leases[0].replace_client(client)
|
|
843
|
+
for redundant_lease in leases[1:]:
|
|
844
|
+
redundant_lease.disable()
|
|
845
|
+
self._client_leases_by_server_id[server.id] = [leases[0]]
|
|
846
|
+
live_client: _McpSessionClient = leases[0]
|
|
847
|
+
else:
|
|
848
|
+
live_client = self._register_client_lease(
|
|
849
|
+
server_id=server.id,
|
|
850
|
+
client=client,
|
|
851
|
+
)
|
|
852
|
+
if server.id in self._clients_by_server_id:
|
|
853
|
+
self._clients_by_server_id[server.id] = live_client
|
|
854
|
+
elif server.id in self._prompt_clients_by_server_id:
|
|
855
|
+
self._prompt_clients_by_server_id[server.id] = live_client
|
|
856
|
+
else:
|
|
857
|
+
# Keep a configured-but-empty server connected to this live manager so
|
|
858
|
+
# status and later prompt materialization operate on the owned connection.
|
|
859
|
+
self._clients_by_server_id[server.id] = live_client
|
|
860
|
+
if server.id in self._prompt_clients_by_server_id:
|
|
861
|
+
self._prompt_clients_by_server_id[server.id] = live_client
|
|
862
|
+
self._disabled_server_ids.discard(server.id)
|
|
863
|
+
# The replacement has just been compared with every frozen
|
|
864
|
+
# surface above. Clear stale state inherited from the closed
|
|
865
|
+
# transport so status reflects the validated connection.
|
|
866
|
+
self._tool_stale_server_ids.discard(server.id)
|
|
867
|
+
self._resource_stale_server_ids.discard(server.id)
|
|
868
|
+
self._prompt_stale_server_ids.discard(server.id)
|
|
869
|
+
self._sync_stale_catalog_snapshot_metadata()
|
|
870
|
+
except Exception:
|
|
871
|
+
client.close()
|
|
872
|
+
raise
|
|
873
|
+
return {
|
|
874
|
+
**self.server_lifecycle_status(server_id=server.id),
|
|
875
|
+
"changed": True,
|
|
876
|
+
"action": action,
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
def enable_server(self, *, server_id: str) -> dict[str, Any]:
|
|
880
|
+
return self._connect_server(server_id=server_id, action="enable")
|
|
881
|
+
|
|
882
|
+
def restart_server(self, *, server_id: str) -> dict[str, Any]:
|
|
883
|
+
return self._connect_server(server_id=server_id, action="restart")
|
|
884
|
+
|
|
885
|
+
def _refresh_stale_state_from_clients(self) -> None:
|
|
886
|
+
for server_id, client in self._clients_by_server_id.items():
|
|
887
|
+
if isinstance(client, _McpClientLease) and not client.enabled:
|
|
888
|
+
continue
|
|
889
|
+
client.observe_notifications()
|
|
890
|
+
if client.tools_list_changed:
|
|
891
|
+
self._tool_stale_server_ids.add(server_id)
|
|
892
|
+
if client.resources_list_changed and server_id in self._resource_snapshotted_server_ids:
|
|
893
|
+
self._resource_stale_server_ids.add(server_id)
|
|
894
|
+
for server_id, client in self._prompt_clients_by_server_id.items():
|
|
895
|
+
if isinstance(client, _McpClientLease) and not client.enabled:
|
|
896
|
+
continue
|
|
897
|
+
client.observe_notifications()
|
|
898
|
+
if client.prompts_list_changed and server_id in self._prompt_loaded_server_ids:
|
|
899
|
+
self._prompt_stale_server_ids.add(server_id)
|
|
900
|
+
|
|
901
|
+
def _sync_stale_catalog_snapshot_metadata(self) -> None:
|
|
902
|
+
self._catalog_snapshot["tool_stale_server_ids"] = sorted(self._tool_stale_server_ids)
|
|
903
|
+
self._catalog_snapshot["resource_stale_server_ids"] = sorted(
|
|
904
|
+
self._resource_stale_server_ids
|
|
905
|
+
)
|
|
906
|
+
self._catalog_snapshot["prompt_stale_server_ids"] = sorted(self._prompt_stale_server_ids)
|
|
907
|
+
|
|
908
|
+
def catalog_snapshot_metadata(self) -> dict[str, Any]:
|
|
909
|
+
self._refresh_stale_state_from_clients()
|
|
910
|
+
self._sync_stale_catalog_snapshot_metadata()
|
|
911
|
+
snapshot = copy.deepcopy(self._catalog_snapshot)
|
|
912
|
+
server_catalogs = snapshot.get("server_catalogs")
|
|
913
|
+
if isinstance(server_catalogs, list):
|
|
914
|
+
for entry in server_catalogs:
|
|
915
|
+
if not isinstance(entry, dict):
|
|
916
|
+
continue
|
|
917
|
+
server_id = str(entry.get("server_id") or "").strip()
|
|
918
|
+
client = self._clients_by_server_id.get(server_id)
|
|
919
|
+
if client is not None and not (
|
|
920
|
+
isinstance(client, _McpClientLease) and not client.enabled
|
|
921
|
+
):
|
|
922
|
+
entry["tools_list_changed"] = client.tools_list_changed
|
|
923
|
+
entry["resources_list_changed"] = client.resources_list_changed
|
|
924
|
+
entry["tools_snapshot_stale"] = server_id in self._tool_stale_server_ids
|
|
925
|
+
entry["resources_snapshot_stale"] = server_id in self._resource_stale_server_ids
|
|
926
|
+
if entry.get("transport") == "http":
|
|
927
|
+
entry["session_negotiated"] = client.session_negotiated
|
|
928
|
+
prompt_server_catalogs = snapshot.get("prompt_server_catalogs")
|
|
929
|
+
if isinstance(prompt_server_catalogs, list):
|
|
930
|
+
for entry in prompt_server_catalogs:
|
|
931
|
+
if not isinstance(entry, dict):
|
|
932
|
+
continue
|
|
933
|
+
server_id = str(entry.get("server_id") or "").strip()
|
|
934
|
+
client = self._prompt_clients_by_server_id.get(server_id)
|
|
935
|
+
if server_id in self._prompt_stale_server_ids:
|
|
936
|
+
entry["prompt_snapshot_stale"] = True
|
|
937
|
+
else:
|
|
938
|
+
entry.pop("prompt_snapshot_stale", None)
|
|
939
|
+
if client is not None and not (
|
|
940
|
+
isinstance(client, _McpClientLease) and not client.enabled
|
|
941
|
+
):
|
|
942
|
+
if client.prompts_list_changed:
|
|
943
|
+
entry["prompts_list_changed"] = True
|
|
944
|
+
else:
|
|
945
|
+
entry.pop("prompts_list_changed", None)
|
|
946
|
+
if entry.get("transport") == "http":
|
|
947
|
+
entry["session_negotiated"] = client.session_negotiated
|
|
948
|
+
return snapshot
|
|
949
|
+
|
|
950
|
+
def execution_context_summary(self) -> dict[str, Any]:
|
|
951
|
+
self._ensure_tool_snapshot()
|
|
952
|
+
snapshot = self.catalog_snapshot_metadata()
|
|
953
|
+
servers: list[dict[str, Any]] = []
|
|
954
|
+
for raw_entry in snapshot.get("server_catalogs") or []:
|
|
955
|
+
if not isinstance(raw_entry, dict):
|
|
956
|
+
continue
|
|
957
|
+
server_id = str(raw_entry.get("server_id") or "").strip()
|
|
958
|
+
if not server_id:
|
|
959
|
+
continue
|
|
960
|
+
tool_names = [
|
|
961
|
+
str(name).strip()
|
|
962
|
+
for name in raw_entry.get("exposed_tool_names") or []
|
|
963
|
+
if str(name).strip()
|
|
964
|
+
]
|
|
965
|
+
servers.append(
|
|
966
|
+
{
|
|
967
|
+
"server_id": server_id,
|
|
968
|
+
"tool_names": tool_names,
|
|
969
|
+
"resources_available": bool(raw_entry.get("snapshotted_resource_count", 0)),
|
|
970
|
+
}
|
|
971
|
+
)
|
|
972
|
+
return {
|
|
973
|
+
"active_server_ids": [server.id for server in self._active_servers],
|
|
974
|
+
"servers": servers,
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
def scope_for_forge_task(
|
|
978
|
+
self,
|
|
979
|
+
*,
|
|
980
|
+
task_scope: ForgeTaskMcpScope | None,
|
|
981
|
+
) -> ForgeTaskScopedMcpManager:
|
|
982
|
+
if task_scope is None or task_scope.is_empty:
|
|
983
|
+
return ForgeTaskScopedMcpManager.without_live_bootstrap(
|
|
984
|
+
resolved_config=self.resolved_config,
|
|
985
|
+
workspace_root=self.workspace_root,
|
|
986
|
+
runtime_kind=self.runtime_kind,
|
|
987
|
+
task_scope=task_scope,
|
|
988
|
+
close_delegate=self,
|
|
989
|
+
)
|
|
990
|
+
return ForgeTaskScopedMcpManager(base_manager=self, task_scope=task_scope)
|
|
991
|
+
|
|
992
|
+
def _resource_list_tool_parameters(self) -> dict[str, Any]:
|
|
993
|
+
return {
|
|
994
|
+
"type": "object",
|
|
995
|
+
"properties": {
|
|
996
|
+
"server_id": {
|
|
997
|
+
"type": "string",
|
|
998
|
+
"description": "Optional exact MCP server id filter.",
|
|
999
|
+
},
|
|
1000
|
+
"query": {
|
|
1001
|
+
"type": "string",
|
|
1002
|
+
"description": "Optional case-insensitive filter over resource uri, name, or description.",
|
|
1003
|
+
},
|
|
1004
|
+
"limit": {
|
|
1005
|
+
"type": "integer",
|
|
1006
|
+
"minimum": 1,
|
|
1007
|
+
"maximum": _MAX_MCP_RESOURCE_LIST_LIMIT,
|
|
1008
|
+
"description": (
|
|
1009
|
+
f"Maximum resources to return. Defaults to {_DEFAULT_MCP_RESOURCE_LIST_LIMIT}."
|
|
1010
|
+
),
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
"required": [],
|
|
1014
|
+
"additionalProperties": False,
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
def _resource_read_tool_parameters(self) -> dict[str, Any]:
|
|
1018
|
+
return {
|
|
1019
|
+
"type": "object",
|
|
1020
|
+
"properties": {
|
|
1021
|
+
"server_id": {
|
|
1022
|
+
"type": "string",
|
|
1023
|
+
"description": "Exact MCP server id from mcp_resources_list.",
|
|
1024
|
+
},
|
|
1025
|
+
"uri": {
|
|
1026
|
+
"type": "string",
|
|
1027
|
+
"description": "Exact snapshotted resource URI from mcp_resources_list.",
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
"required": ["server_id", "uri"],
|
|
1031
|
+
"additionalProperties": False,
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
def _normalize_resource_list_limit(self, value: Any) -> int:
|
|
1035
|
+
if value is None:
|
|
1036
|
+
return _DEFAULT_MCP_RESOURCE_LIST_LIMIT
|
|
1037
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1038
|
+
raise RuntimeError("mcp_resources_list limit must be an integer.")
|
|
1039
|
+
if value < 1 or value > _MAX_MCP_RESOURCE_LIST_LIMIT:
|
|
1040
|
+
raise RuntimeError(
|
|
1041
|
+
f"mcp_resources_list limit must be between 1 and {_MAX_MCP_RESOURCE_LIST_LIMIT}."
|
|
1042
|
+
)
|
|
1043
|
+
return value
|
|
1044
|
+
|
|
1045
|
+
def _normalize_required_argument(self, arguments: dict[str, Any], *, field_name: str) -> str:
|
|
1046
|
+
value = arguments.get(field_name)
|
|
1047
|
+
if not isinstance(value, str) or not value.strip():
|
|
1048
|
+
raise RuntimeError(f"{field_name} must be a non-empty string.")
|
|
1049
|
+
return value.strip()
|
|
1050
|
+
|
|
1051
|
+
def _list_snapshotted_resources(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
1052
|
+
server_id_value = arguments.get("server_id")
|
|
1053
|
+
if server_id_value is None:
|
|
1054
|
+
server_id = None
|
|
1055
|
+
elif isinstance(server_id_value, str) and server_id_value.strip():
|
|
1056
|
+
server_id = server_id_value.strip()
|
|
1057
|
+
else:
|
|
1058
|
+
raise RuntimeError("server_id must be a non-empty string when present.")
|
|
1059
|
+
query_value = arguments.get("query")
|
|
1060
|
+
if query_value is None:
|
|
1061
|
+
query = None
|
|
1062
|
+
elif isinstance(query_value, str):
|
|
1063
|
+
query = query_value.strip().casefold() or None
|
|
1064
|
+
else:
|
|
1065
|
+
raise RuntimeError("query must be a string when present.")
|
|
1066
|
+
limit = self._normalize_resource_list_limit(arguments.get("limit"))
|
|
1067
|
+
matching_resources: list[dict[str, Any]] = []
|
|
1068
|
+
for record in self._resource_records:
|
|
1069
|
+
if server_id is not None and record.server.id != server_id:
|
|
1070
|
+
continue
|
|
1071
|
+
if query is not None:
|
|
1072
|
+
haystack = " ".join(
|
|
1073
|
+
part
|
|
1074
|
+
for part in (
|
|
1075
|
+
record.server.id,
|
|
1076
|
+
record.resource.uri,
|
|
1077
|
+
record.resource.name,
|
|
1078
|
+
record.resource.description or "",
|
|
1079
|
+
)
|
|
1080
|
+
if part
|
|
1081
|
+
).casefold()
|
|
1082
|
+
if query not in haystack:
|
|
1083
|
+
continue
|
|
1084
|
+
matching_resources.append(record.resource.as_tool_payload(server_id=record.server.id))
|
|
1085
|
+
returned_resources = matching_resources[:limit]
|
|
1086
|
+
return {
|
|
1087
|
+
"resources": returned_resources,
|
|
1088
|
+
"returned_count": len(returned_resources),
|
|
1089
|
+
"matching_count": len(matching_resources),
|
|
1090
|
+
"total_snapshot_count": len(self._resource_records),
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
def _read_snapshotted_resource(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
1094
|
+
server_id = self._normalize_required_argument(arguments, field_name="server_id")
|
|
1095
|
+
uri = self._normalize_required_argument(arguments, field_name="uri")
|
|
1096
|
+
record = self._resource_lookup.get((server_id, uri))
|
|
1097
|
+
if record is None:
|
|
1098
|
+
raise RuntimeError(
|
|
1099
|
+
"Blocked: MCP resource reads are limited to the frozen session snapshot; "
|
|
1100
|
+
f"no snapshotted resource matches server '{server_id}' and uri '{uri}'."
|
|
1101
|
+
)
|
|
1102
|
+
result = record.client.read_resource(resource_uri=uri, allowed_uris=frozenset({uri}))
|
|
1103
|
+
payload: dict[str, Any] = {
|
|
1104
|
+
"server_id": record.server.id,
|
|
1105
|
+
"uri": record.resource.uri,
|
|
1106
|
+
"name": record.resource.name,
|
|
1107
|
+
"content_summary": result.content_summary,
|
|
1108
|
+
"contents": [
|
|
1109
|
+
item.as_tool_payload(server_id=record.server.id, resource_uri=record.resource.uri)
|
|
1110
|
+
for item in result.contents
|
|
1111
|
+
],
|
|
1112
|
+
}
|
|
1113
|
+
mime_type = result.mime_type or record.resource.mime_type
|
|
1114
|
+
if mime_type:
|
|
1115
|
+
payload["mime_type"] = mime_type
|
|
1116
|
+
if record.resource.description:
|
|
1117
|
+
payload["description"] = record.resource.description
|
|
1118
|
+
if record.resource.size is not None:
|
|
1119
|
+
payload["size"] = record.resource.size
|
|
1120
|
+
if any(item.text is not None for item in result.contents):
|
|
1121
|
+
payload["text"] = build_untrusted_mcp_text_block(
|
|
1122
|
+
source_type="resource_read",
|
|
1123
|
+
server_id=record.server.id,
|
|
1124
|
+
source_name=record.resource.uri,
|
|
1125
|
+
text=result.text,
|
|
1126
|
+
mime_type=mime_type,
|
|
1127
|
+
)
|
|
1128
|
+
return payload
|
|
1129
|
+
|
|
1130
|
+
def _build_resource_tool_bindings(self) -> tuple[McpHostToolBinding, ...]:
|
|
1131
|
+
if not self._resource_records:
|
|
1132
|
+
return ()
|
|
1133
|
+
return (
|
|
1134
|
+
McpHostToolBinding(
|
|
1135
|
+
tool_name="mcp_resources_list",
|
|
1136
|
+
tool_alias="mcp_resources_list",
|
|
1137
|
+
description=(
|
|
1138
|
+
"List read-only MCP resources from the frozen session snapshot across active "
|
|
1139
|
+
"servers."
|
|
1140
|
+
),
|
|
1141
|
+
parameters=self._resource_list_tool_parameters(),
|
|
1142
|
+
run_handler=self._list_snapshotted_resources,
|
|
1143
|
+
),
|
|
1144
|
+
McpHostToolBinding(
|
|
1145
|
+
tool_name="mcp_resource_read",
|
|
1146
|
+
tool_alias="mcp_resource_read",
|
|
1147
|
+
description=(
|
|
1148
|
+
"Read one frozen-snapshot MCP resource by server_id and uri. Arbitrary "
|
|
1149
|
+
"unsnapshotted URI reads are blocked."
|
|
1150
|
+
),
|
|
1151
|
+
parameters=self._resource_read_tool_parameters(),
|
|
1152
|
+
run_handler=self._read_snapshotted_resource,
|
|
1153
|
+
),
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
def _normalize_prompt_list_limit(self, value: Any) -> int:
|
|
1157
|
+
if value is None:
|
|
1158
|
+
return _DEFAULT_MCP_PROMPT_LIST_LIMIT
|
|
1159
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1160
|
+
raise RuntimeError("prompt list limit must be an integer.")
|
|
1161
|
+
if value < 1 or value > _MAX_MCP_PROMPT_LIST_LIMIT:
|
|
1162
|
+
raise RuntimeError(
|
|
1163
|
+
f"prompt list limit must be between 1 and {_MAX_MCP_PROMPT_LIST_LIMIT}."
|
|
1164
|
+
)
|
|
1165
|
+
return value
|
|
1166
|
+
|
|
1167
|
+
def _close_prompt_clients(self) -> None:
|
|
1168
|
+
clients = list(self._prompt_clients_by_server_id.values())
|
|
1169
|
+
self._prompt_clients_by_server_id = {}
|
|
1170
|
+
for client in clients:
|
|
1171
|
+
if client in self._clients_by_server_id.values():
|
|
1172
|
+
continue
|
|
1173
|
+
client.close()
|
|
1174
|
+
|
|
1175
|
+
def _initial_prompt_server_catalog(self, server: ResolvedMcpServer) -> dict[str, Any]:
|
|
1176
|
+
return {
|
|
1177
|
+
"server_id": server.id,
|
|
1178
|
+
"transport": server.transport,
|
|
1179
|
+
"prompts_mode": server.prompts_mode,
|
|
1180
|
+
"prompt_snapshot_loaded": False,
|
|
1181
|
+
"prompt_snapshot_failed": False,
|
|
1182
|
+
"snapshotted_prompt_count": 0,
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
def _sync_prompt_catalog_snapshot_metadata(self) -> None:
|
|
1186
|
+
prompt_enabled_server_ids = [server.id for server in self._prompt_enabled_servers]
|
|
1187
|
+
prompt_snapshotted_server_ids = [
|
|
1188
|
+
server.id
|
|
1189
|
+
for server in self._prompt_enabled_servers
|
|
1190
|
+
if server.id in self._prompt_loaded_server_ids
|
|
1191
|
+
]
|
|
1192
|
+
prompt_snapshot_complete = len(prompt_snapshotted_server_ids) == len(
|
|
1193
|
+
prompt_enabled_server_ids
|
|
1194
|
+
)
|
|
1195
|
+
prompt_server_catalogs = [
|
|
1196
|
+
copy.deepcopy(self._prompt_server_catalogs_by_id[server.id])
|
|
1197
|
+
for server in self._prompt_enabled_servers
|
|
1198
|
+
]
|
|
1199
|
+
self._catalog_snapshot["prompt_enabled_server_ids"] = prompt_enabled_server_ids
|
|
1200
|
+
self._catalog_snapshot["prompt_snapshotted_server_ids"] = prompt_snapshotted_server_ids
|
|
1201
|
+
self._catalog_snapshot["prompt_snapshot_complete"] = prompt_snapshot_complete
|
|
1202
|
+
self._catalog_snapshot["prompt_snapshot_partial"] = bool(
|
|
1203
|
+
prompt_snapshotted_server_ids
|
|
1204
|
+
) and (not prompt_snapshot_complete)
|
|
1205
|
+
self._catalog_snapshot["prompt_server_catalogs"] = prompt_server_catalogs
|
|
1206
|
+
snapshotted_prompt_count = sum(
|
|
1207
|
+
len(records) for records in self._prompt_records_by_server_id.values()
|
|
1208
|
+
)
|
|
1209
|
+
self._catalog_snapshot["snapshotted_prompt_count"] = snapshotted_prompt_count
|
|
1210
|
+
self._catalog_snapshot["manual_prompt_surface_enabled"] = snapshotted_prompt_count > 0
|
|
1211
|
+
self._sync_stale_catalog_snapshot_metadata()
|
|
1212
|
+
|
|
1213
|
+
def _iter_ordered_prompt_records(
|
|
1214
|
+
self,
|
|
1215
|
+
*,
|
|
1216
|
+
server_id: str | None = None,
|
|
1217
|
+
) -> tuple[_SnapshottedPromptRecord, ...]:
|
|
1218
|
+
if server_id is not None:
|
|
1219
|
+
return self._prompt_records_by_server_id.get(server_id, ())
|
|
1220
|
+
ordered_records: list[_SnapshottedPromptRecord] = []
|
|
1221
|
+
for server in self._prompt_enabled_servers:
|
|
1222
|
+
ordered_records.extend(self._prompt_records_by_server_id.get(server.id, ()))
|
|
1223
|
+
return tuple(ordered_records)
|
|
1224
|
+
|
|
1225
|
+
def _prompt_target_server(self, *, server_id: str) -> ResolvedMcpServer:
|
|
1226
|
+
normalized_server_id = str(server_id or "").strip()
|
|
1227
|
+
if not normalized_server_id:
|
|
1228
|
+
raise RuntimeError("server_id must be a non-empty string.")
|
|
1229
|
+
prompt_server = self._prompt_enabled_servers_by_id.get(normalized_server_id)
|
|
1230
|
+
if prompt_server is not None:
|
|
1231
|
+
return prompt_server
|
|
1232
|
+
active_server = self._active_servers_by_id.get(normalized_server_id)
|
|
1233
|
+
if active_server is not None:
|
|
1234
|
+
raise RuntimeError(
|
|
1235
|
+
f"MCP server '{normalized_server_id}' does not have prompts enabled for runtime "
|
|
1236
|
+
f"'{self.runtime_kind.value}'."
|
|
1237
|
+
)
|
|
1238
|
+
if normalized_server_id in self._resolved_servers_by_id:
|
|
1239
|
+
raise RuntimeError(
|
|
1240
|
+
f"MCP server '{normalized_server_id}' is not active for runtime "
|
|
1241
|
+
f"'{self.runtime_kind.value}'."
|
|
1242
|
+
)
|
|
1243
|
+
raise RuntimeError(f"Unknown MCP server '{normalized_server_id}'.")
|
|
1244
|
+
|
|
1245
|
+
def _raise_prompt_server_error(self, *, server_id: str) -> None:
|
|
1246
|
+
error = self._prompt_server_errors.get(server_id)
|
|
1247
|
+
if error is None:
|
|
1248
|
+
return
|
|
1249
|
+
if isinstance(error, Exception):
|
|
1250
|
+
raise error
|
|
1251
|
+
raise RuntimeError(str(error))
|
|
1252
|
+
|
|
1253
|
+
def _drop_prompt_server_snapshot(self, server_id: str) -> None:
|
|
1254
|
+
old_records = self._prompt_records_by_server_id.pop(server_id, ())
|
|
1255
|
+
if old_records:
|
|
1256
|
+
old_lookup_keys = {record.lookup_key for record in old_records}
|
|
1257
|
+
self._prompt_records = tuple(
|
|
1258
|
+
record
|
|
1259
|
+
for record in self._prompt_records
|
|
1260
|
+
if record.lookup_key not in old_lookup_keys
|
|
1261
|
+
)
|
|
1262
|
+
for lookup_key in old_lookup_keys:
|
|
1263
|
+
self._prompt_lookup.pop(lookup_key, None)
|
|
1264
|
+
old_client = self._prompt_clients_by_server_id.pop(server_id, None)
|
|
1265
|
+
if old_client is not None and old_client not in self._clients_by_server_id.values():
|
|
1266
|
+
old_client.close()
|
|
1267
|
+
self._prompt_loaded_server_ids.discard(server_id)
|
|
1268
|
+
self._prompt_server_errors.pop(server_id, None)
|
|
1269
|
+
|
|
1270
|
+
def _ensure_prompt_server_snapshot(
|
|
1271
|
+
self,
|
|
1272
|
+
server: ResolvedMcpServer,
|
|
1273
|
+
*,
|
|
1274
|
+
refresh: bool = False,
|
|
1275
|
+
) -> None:
|
|
1276
|
+
if self._closed:
|
|
1277
|
+
raise RuntimeError("MCP manager is closed.")
|
|
1278
|
+
if server.id in self._disabled_server_ids:
|
|
1279
|
+
raise RuntimeError(f"MCP server '{server.id}' is disabled for this session.")
|
|
1280
|
+
if refresh:
|
|
1281
|
+
self._prompt_server_errors.pop(server.id, None)
|
|
1282
|
+
if server.id in self._prompt_loaded_server_ids and not refresh:
|
|
1283
|
+
return
|
|
1284
|
+
if server.id in self._prompt_server_errors:
|
|
1285
|
+
self._raise_prompt_server_error(server_id=server.id)
|
|
1286
|
+
replacing_loaded_snapshot = refresh and server.id in self._prompt_loaded_server_ids
|
|
1287
|
+
replaced_prompt_count = (
|
|
1288
|
+
len(self._prompt_records_by_server_id.get(server.id, ()))
|
|
1289
|
+
if replacing_loaded_snapshot
|
|
1290
|
+
else 0
|
|
1291
|
+
)
|
|
1292
|
+
if server.trust != "explicit":
|
|
1293
|
+
error = ConfigError(
|
|
1294
|
+
f"MCP server '{server.id}' uses unsupported trust mode '{server.trust}'. "
|
|
1295
|
+
"Live MCP prompt access currently requires trust='explicit'."
|
|
1296
|
+
)
|
|
1297
|
+
self._prompt_server_errors[server.id] = error
|
|
1298
|
+
self._prompt_server_catalogs_by_id[server.id]["prompt_snapshot_failed"] = True
|
|
1299
|
+
self._sync_prompt_catalog_snapshot_metadata()
|
|
1300
|
+
raise error
|
|
1301
|
+
existing_client = self._clients_by_server_id.get(server.id)
|
|
1302
|
+
raw_client: _McpLiveClient | None = None
|
|
1303
|
+
client: _McpSessionClient
|
|
1304
|
+
if existing_client is not None and not refresh:
|
|
1305
|
+
client = existing_client
|
|
1306
|
+
else:
|
|
1307
|
+
raw_client = self._build_live_client(server)
|
|
1308
|
+
client = raw_client
|
|
1309
|
+
try:
|
|
1310
|
+
client.ensure_initialized()
|
|
1311
|
+
listed_prompts: tuple[McpListedPrompt, ...] = ()
|
|
1312
|
+
if client.supports_prompts:
|
|
1313
|
+
listed_prompts = client.list_prompts()
|
|
1314
|
+
prompt_count = len(listed_prompts)
|
|
1315
|
+
if prompt_count > _MAX_LISTED_MCP_PROMPTS_PER_SERVER:
|
|
1316
|
+
raise ConfigError(
|
|
1317
|
+
"MCP prompt exposure is too large for server "
|
|
1318
|
+
f"'{server.id}' ({prompt_count} prompts). Narrow server-side prompt "
|
|
1319
|
+
"exposure or disable prompts_mode."
|
|
1320
|
+
)
|
|
1321
|
+
total_prompts = len(self._prompt_records) - replaced_prompt_count + prompt_count
|
|
1322
|
+
if total_prompts > _MAX_LISTED_MCP_PROMPTS_TOTAL:
|
|
1323
|
+
raise ConfigError(
|
|
1324
|
+
"MCP prompt exposure is too large for one session "
|
|
1325
|
+
f"({total_prompts} prompts). Narrow server-side prompt exposure or "
|
|
1326
|
+
"disable prompts_mode."
|
|
1327
|
+
)
|
|
1328
|
+
if raw_client is not None and isinstance(existing_client, _McpClientLease):
|
|
1329
|
+
self._validate_replacement_client(
|
|
1330
|
+
server=server,
|
|
1331
|
+
client=raw_client,
|
|
1332
|
+
validate_prompts=False,
|
|
1333
|
+
)
|
|
1334
|
+
existing_client.replace_client(raw_client)
|
|
1335
|
+
client = existing_client
|
|
1336
|
+
raw_client = None
|
|
1337
|
+
elif raw_client is not None:
|
|
1338
|
+
client = self._register_client_lease(server_id=server.id, client=raw_client)
|
|
1339
|
+
raw_client = None
|
|
1340
|
+
self._clients_by_server_id[server.id] = client
|
|
1341
|
+
new_records = tuple(
|
|
1342
|
+
_SnapshottedPromptRecord(
|
|
1343
|
+
server=server,
|
|
1344
|
+
client=client,
|
|
1345
|
+
prompt=prompt,
|
|
1346
|
+
)
|
|
1347
|
+
for prompt in listed_prompts
|
|
1348
|
+
)
|
|
1349
|
+
if refresh:
|
|
1350
|
+
self._drop_prompt_server_snapshot(server.id)
|
|
1351
|
+
self._prompt_records_by_server_id[server.id] = new_records
|
|
1352
|
+
if new_records:
|
|
1353
|
+
self._prompt_records = self._prompt_records + new_records
|
|
1354
|
+
for record in new_records:
|
|
1355
|
+
self._prompt_lookup[record.lookup_key] = record
|
|
1356
|
+
self._prompt_clients_by_server_id[server.id] = client
|
|
1357
|
+
self._prompt_loaded_server_ids.add(server.id)
|
|
1358
|
+
self._prompt_snapshot_loaded = len(self._prompt_loaded_server_ids) == len(
|
|
1359
|
+
self._prompt_enabled_servers
|
|
1360
|
+
)
|
|
1361
|
+
catalog_entry = self._prompt_server_catalogs_by_id[server.id]
|
|
1362
|
+
catalog_entry["prompt_snapshot_loaded"] = True
|
|
1363
|
+
catalog_entry["prompt_snapshot_failed"] = False
|
|
1364
|
+
if client.prompts_list_changed:
|
|
1365
|
+
catalog_entry["prompts_list_changed"] = True
|
|
1366
|
+
self._prompt_stale_server_ids.add(server.id)
|
|
1367
|
+
else:
|
|
1368
|
+
catalog_entry.pop("prompts_list_changed", None)
|
|
1369
|
+
self._prompt_stale_server_ids.discard(server.id)
|
|
1370
|
+
if server.id in self._prompt_stale_server_ids:
|
|
1371
|
+
catalog_entry["prompt_snapshot_stale"] = True
|
|
1372
|
+
else:
|
|
1373
|
+
catalog_entry.pop("prompt_snapshot_stale", None)
|
|
1374
|
+
catalog_entry["prompts_capability_advertised"] = client.supports_prompts
|
|
1375
|
+
catalog_entry["snapshotted_prompt_count"] = prompt_count
|
|
1376
|
+
self._sync_prompt_catalog_snapshot_metadata()
|
|
1377
|
+
except Exception as exc:
|
|
1378
|
+
if raw_client is not None:
|
|
1379
|
+
raw_client.close()
|
|
1380
|
+
self._prompt_server_errors[server.id] = exc
|
|
1381
|
+
catalog_entry = self._prompt_server_catalogs_by_id[server.id]
|
|
1382
|
+
if not replacing_loaded_snapshot:
|
|
1383
|
+
catalog_entry["prompt_snapshot_failed"] = True
|
|
1384
|
+
catalog_entry["prompt_snapshot_loaded"] = False
|
|
1385
|
+
catalog_entry.pop("prompts_list_changed", None)
|
|
1386
|
+
catalog_entry.pop("prompts_capability_advertised", None)
|
|
1387
|
+
catalog_entry["snapshotted_prompt_count"] = 0
|
|
1388
|
+
self._prompt_snapshot_loaded = False
|
|
1389
|
+
if server.id in self._prompt_stale_server_ids:
|
|
1390
|
+
catalog_entry["prompt_snapshot_stale"] = True
|
|
1391
|
+
else:
|
|
1392
|
+
catalog_entry.pop("prompt_snapshot_stale", None)
|
|
1393
|
+
self._sync_prompt_catalog_snapshot_metadata()
|
|
1394
|
+
raise
|
|
1395
|
+
|
|
1396
|
+
def _ensure_prompt_snapshot(
|
|
1397
|
+
self,
|
|
1398
|
+
*,
|
|
1399
|
+
server_id: str | None = None,
|
|
1400
|
+
refresh: bool = False,
|
|
1401
|
+
) -> None:
|
|
1402
|
+
if server_id is None:
|
|
1403
|
+
if self._prompt_snapshot_loaded and not refresh:
|
|
1404
|
+
return
|
|
1405
|
+
for server in self._prompt_enabled_servers:
|
|
1406
|
+
self._ensure_prompt_server_snapshot(server, refresh=refresh)
|
|
1407
|
+
self._prompt_snapshot_loaded = True
|
|
1408
|
+
self._sync_prompt_catalog_snapshot_metadata()
|
|
1409
|
+
return
|
|
1410
|
+
self._ensure_prompt_server_snapshot(
|
|
1411
|
+
self._prompt_target_server(server_id=server_id),
|
|
1412
|
+
refresh=refresh,
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
def list_prompts(
|
|
1416
|
+
self,
|
|
1417
|
+
*,
|
|
1418
|
+
server_id: str | None = None,
|
|
1419
|
+
query: str | None = None,
|
|
1420
|
+
limit: int = _DEFAULT_MCP_PROMPT_LIST_LIMIT,
|
|
1421
|
+
refresh: bool = False,
|
|
1422
|
+
) -> dict[str, Any]:
|
|
1423
|
+
if server_id is None:
|
|
1424
|
+
normalized_server_id = None
|
|
1425
|
+
else:
|
|
1426
|
+
normalized_server_id = str(server_id).strip()
|
|
1427
|
+
if not normalized_server_id:
|
|
1428
|
+
raise RuntimeError("server_id must be a non-empty string when present.")
|
|
1429
|
+
self._ensure_prompt_snapshot(server_id=normalized_server_id, refresh=refresh)
|
|
1430
|
+
self._refresh_stale_state_from_clients()
|
|
1431
|
+
self._sync_stale_catalog_snapshot_metadata()
|
|
1432
|
+
normalized_query = str(query or "").strip().casefold() or None
|
|
1433
|
+
normalized_limit = self._normalize_prompt_list_limit(limit)
|
|
1434
|
+
matching_prompts: list[dict[str, Any]] = []
|
|
1435
|
+
for record in self._iter_ordered_prompt_records(server_id=normalized_server_id):
|
|
1436
|
+
if normalized_query is not None:
|
|
1437
|
+
haystack = " ".join(
|
|
1438
|
+
part
|
|
1439
|
+
for part in (
|
|
1440
|
+
record.server.id,
|
|
1441
|
+
record.prompt.name,
|
|
1442
|
+
record.prompt.title or "",
|
|
1443
|
+
record.prompt.description or "",
|
|
1444
|
+
" ".join(argument.name for argument in record.prompt.arguments),
|
|
1445
|
+
)
|
|
1446
|
+
if part
|
|
1447
|
+
).casefold()
|
|
1448
|
+
if normalized_query not in haystack:
|
|
1449
|
+
continue
|
|
1450
|
+
matching_prompts.append(record.prompt.as_payload(server_id=record.server.id))
|
|
1451
|
+
returned_prompts = matching_prompts[:normalized_limit]
|
|
1452
|
+
payload: dict[str, Any] = {
|
|
1453
|
+
"prompts": returned_prompts,
|
|
1454
|
+
"returned_count": len(returned_prompts),
|
|
1455
|
+
"matching_count": len(matching_prompts),
|
|
1456
|
+
"total_snapshot_count": len(self._prompt_records),
|
|
1457
|
+
}
|
|
1458
|
+
if refresh:
|
|
1459
|
+
payload["refresh_performed"] = True
|
|
1460
|
+
if self._prompt_stale_server_ids:
|
|
1461
|
+
payload["stale_server_ids"] = sorted(self._prompt_stale_server_ids)
|
|
1462
|
+
payload["snapshot_complete"] = bool(
|
|
1463
|
+
self._catalog_snapshot.get("prompt_snapshot_complete")
|
|
1464
|
+
)
|
|
1465
|
+
payload["snapshot_partial"] = bool(
|
|
1466
|
+
self._catalog_snapshot.get("prompt_snapshot_partial")
|
|
1467
|
+
)
|
|
1468
|
+
return payload
|
|
1469
|
+
|
|
1470
|
+
def get_prompt(
|
|
1471
|
+
self,
|
|
1472
|
+
*,
|
|
1473
|
+
server_id: str,
|
|
1474
|
+
prompt_name: str,
|
|
1475
|
+
arguments: dict[str, str] | None = None,
|
|
1476
|
+
refresh: bool = False,
|
|
1477
|
+
) -> dict[str, Any]:
|
|
1478
|
+
normalized_server_id = str(server_id or "").strip()
|
|
1479
|
+
normalized_prompt_name = str(prompt_name or "").strip()
|
|
1480
|
+
if not normalized_prompt_name:
|
|
1481
|
+
raise RuntimeError("prompt_name must be a non-empty string.")
|
|
1482
|
+
if arguments is not None and not isinstance(arguments, dict):
|
|
1483
|
+
raise RuntimeError("arguments must be an object mapping strings to strings.")
|
|
1484
|
+
self._ensure_prompt_snapshot(server_id=normalized_server_id, refresh=refresh)
|
|
1485
|
+
self._refresh_stale_state_from_clients()
|
|
1486
|
+
self._sync_stale_catalog_snapshot_metadata()
|
|
1487
|
+
record = self._prompt_lookup.get((normalized_server_id, normalized_prompt_name))
|
|
1488
|
+
if record is None:
|
|
1489
|
+
raise RuntimeError(
|
|
1490
|
+
"Blocked: MCP prompt fetches are limited to the frozen session snapshot; "
|
|
1491
|
+
f"no snapshotted prompt matches server '{normalized_server_id}' and prompt "
|
|
1492
|
+
f"{normalized_prompt_name!r}."
|
|
1493
|
+
)
|
|
1494
|
+
result = record.client.get_prompt(name=record.prompt.name, arguments=arguments)
|
|
1495
|
+
payload: dict[str, Any] = {
|
|
1496
|
+
"server_id": record.server.id,
|
|
1497
|
+
"name": record.prompt.name,
|
|
1498
|
+
"arguments": [argument.as_payload() for argument in record.prompt.arguments],
|
|
1499
|
+
"message_count": len(result.messages),
|
|
1500
|
+
"content_summary": result.content_summary,
|
|
1501
|
+
"messages": [
|
|
1502
|
+
message.as_payload(server_id=record.server.id, prompt_name=record.prompt.name)
|
|
1503
|
+
for message in result.messages
|
|
1504
|
+
],
|
|
1505
|
+
}
|
|
1506
|
+
if refresh:
|
|
1507
|
+
payload["refresh_performed"] = True
|
|
1508
|
+
if normalized_server_id in self._prompt_stale_server_ids:
|
|
1509
|
+
payload["snapshot_stale"] = True
|
|
1510
|
+
if record.prompt.title:
|
|
1511
|
+
payload["title"] = record.prompt.title
|
|
1512
|
+
description = result.description or record.prompt.description
|
|
1513
|
+
if description:
|
|
1514
|
+
payload["description"] = description
|
|
1515
|
+
if arguments:
|
|
1516
|
+
payload["applied_arguments"] = {
|
|
1517
|
+
str(key): str(value) for key, value in arguments.items()
|
|
1518
|
+
}
|
|
1519
|
+
if result.text:
|
|
1520
|
+
payload["text"] = build_untrusted_mcp_text_block(
|
|
1521
|
+
source_type="prompt_get",
|
|
1522
|
+
server_id=record.server.id,
|
|
1523
|
+
source_name=record.prompt.name,
|
|
1524
|
+
text=result.text,
|
|
1525
|
+
)
|
|
1526
|
+
return payload
|
|
1527
|
+
|
|
1528
|
+
def _ensure_tool_snapshot(self) -> None:
|
|
1529
|
+
if self._snapshot_error is not None:
|
|
1530
|
+
if isinstance(self._snapshot_error, Exception):
|
|
1531
|
+
raise self._snapshot_error
|
|
1532
|
+
raise RuntimeError(str(self._snapshot_error))
|
|
1533
|
+
if self._snapshot_loaded:
|
|
1534
|
+
return
|
|
1535
|
+
if self._closed:
|
|
1536
|
+
raise RuntimeError("MCP manager is closed.")
|
|
1537
|
+
self._snapshot_loaded = True
|
|
1538
|
+
if not self._live_runtime_enabled:
|
|
1539
|
+
self._catalog_snapshot["catalog_initialized"] = True
|
|
1540
|
+
return
|
|
1541
|
+
collected_catalogs: tuple[_CollectedServerCatalog, ...] = ()
|
|
1542
|
+
try:
|
|
1543
|
+
collected_catalogs = self._collect_server_catalogs()
|
|
1544
|
+
leased_catalogs: list[_CollectedServerCatalog] = []
|
|
1545
|
+
for catalog in collected_catalogs:
|
|
1546
|
+
if isinstance(catalog.client, _McpClientLease):
|
|
1547
|
+
leased_catalogs.append(catalog)
|
|
1548
|
+
continue
|
|
1549
|
+
lease = self._register_client_lease(
|
|
1550
|
+
server_id=catalog.server.id,
|
|
1551
|
+
client=catalog.client,
|
|
1552
|
+
)
|
|
1553
|
+
if catalog.server.id in self._disabled_server_ids:
|
|
1554
|
+
# A disable issued before first tool materialization must
|
|
1555
|
+
# remain fail-closed after the frozen catalog is built.
|
|
1556
|
+
lease.disable()
|
|
1557
|
+
leased_catalogs.append(replace(catalog, client=lease))
|
|
1558
|
+
collected_catalogs = tuple(leased_catalogs)
|
|
1559
|
+
flat_records: list[_FlatToolRecord] = []
|
|
1560
|
+
resource_records: list[_SnapshottedResourceRecord] = []
|
|
1561
|
+
total_schema_bytes = 0
|
|
1562
|
+
total_resources = 0
|
|
1563
|
+
for catalog in collected_catalogs:
|
|
1564
|
+
for tool in catalog.filtered_tools:
|
|
1565
|
+
parameters = _validate_function_tool_schema(server=catalog.server, tool=tool)
|
|
1566
|
+
total_schema_bytes += _stable_json_size(parameters)
|
|
1567
|
+
flat_records.append(
|
|
1568
|
+
_FlatToolRecord(
|
|
1569
|
+
server=catalog.server,
|
|
1570
|
+
client=catalog.client,
|
|
1571
|
+
tool=tool,
|
|
1572
|
+
parameters=parameters,
|
|
1573
|
+
)
|
|
1574
|
+
)
|
|
1575
|
+
resource_count = len(catalog.listed_resources)
|
|
1576
|
+
if resource_count > _MAX_LISTED_MCP_RESOURCES_PER_SERVER:
|
|
1577
|
+
raise ConfigError(
|
|
1578
|
+
"MCP resource exposure is too large for server "
|
|
1579
|
+
f"'{catalog.server.id}' ({resource_count} resources). Narrow server-side "
|
|
1580
|
+
"resource exposure or disable resources_mode."
|
|
1581
|
+
)
|
|
1582
|
+
total_resources += resource_count
|
|
1583
|
+
for resource in catalog.listed_resources:
|
|
1584
|
+
resource_records.append(
|
|
1585
|
+
_SnapshottedResourceRecord(
|
|
1586
|
+
server=catalog.server,
|
|
1587
|
+
client=catalog.client,
|
|
1588
|
+
resource=resource,
|
|
1589
|
+
)
|
|
1590
|
+
)
|
|
1591
|
+
if len(flat_records) > _MAX_EXPOSED_MCP_TOOLS:
|
|
1592
|
+
raise ConfigError(
|
|
1593
|
+
"MCP tool exposure is too large for one session "
|
|
1594
|
+
f"({len(flat_records)} tools). Narrow exposure with allowed_tools."
|
|
1595
|
+
)
|
|
1596
|
+
if total_schema_bytes > _MAX_TOTAL_SCHEMA_BYTES:
|
|
1597
|
+
raise ConfigError(
|
|
1598
|
+
"MCP tool exposure schema footprint is too large for one session "
|
|
1599
|
+
f"({total_schema_bytes} bytes). Narrow exposure with allowed_tools."
|
|
1600
|
+
)
|
|
1601
|
+
if total_resources > _MAX_LISTED_MCP_RESOURCES_TOTAL:
|
|
1602
|
+
raise ConfigError(
|
|
1603
|
+
"MCP resource exposure is too large for one session "
|
|
1604
|
+
f"({total_resources} resources). Narrow server-side resource exposure or "
|
|
1605
|
+
"disable resources_mode."
|
|
1606
|
+
)
|
|
1607
|
+
alias_map = self._assign_aliases(flat_records)
|
|
1608
|
+
bindings: list[_McpManagerToolBinding] = []
|
|
1609
|
+
server_catalogs: list[dict[str, Any]] = []
|
|
1610
|
+
clients_with_bindings: dict[str, _McpSessionClient] = {}
|
|
1611
|
+
for catalog in collected_catalogs:
|
|
1612
|
+
exposed_aliases: list[str] = []
|
|
1613
|
+
exposed_tool_names: list[str] = []
|
|
1614
|
+
for record in flat_records:
|
|
1615
|
+
if record.server.id != catalog.server.id:
|
|
1616
|
+
continue
|
|
1617
|
+
tool_alias = alias_map[record.unique_key]
|
|
1618
|
+
exposed_aliases.append(tool_alias)
|
|
1619
|
+
exposed_tool_names.append(record.tool.name)
|
|
1620
|
+
clients_with_bindings[catalog.server.id] = catalog.client
|
|
1621
|
+
bindings.append(
|
|
1622
|
+
McpToolBinding(
|
|
1623
|
+
server_id=catalog.server.id,
|
|
1624
|
+
tool_name=record.tool.name,
|
|
1625
|
+
tool_alias=tool_alias,
|
|
1626
|
+
description=build_host_owned_mcp_tool_description(
|
|
1627
|
+
server_id=catalog.server.id,
|
|
1628
|
+
tool_name=record.tool.name,
|
|
1629
|
+
server_description=record.tool.description,
|
|
1630
|
+
),
|
|
1631
|
+
parameters=copy.deepcopy(record.parameters),
|
|
1632
|
+
client=catalog.client,
|
|
1633
|
+
)
|
|
1634
|
+
)
|
|
1635
|
+
server_catalogs.append(
|
|
1636
|
+
{
|
|
1637
|
+
"server_id": catalog.server.id,
|
|
1638
|
+
"transport": catalog.server.transport,
|
|
1639
|
+
"roots_mode": catalog.server.roots_mode,
|
|
1640
|
+
"roots_capability_enabled": catalog.roots_capability_enabled,
|
|
1641
|
+
"resources_mode": catalog.server.resources_mode,
|
|
1642
|
+
"resources_capability_advertised": (
|
|
1643
|
+
catalog.resources_capability_advertised
|
|
1644
|
+
),
|
|
1645
|
+
"resources_snapshot_loaded": catalog.resources_snapshot_loaded,
|
|
1646
|
+
"snapshotted_resource_count": len(catalog.listed_resources),
|
|
1647
|
+
"resources_list_changed": catalog.resources_list_changed,
|
|
1648
|
+
"resources_snapshot_stale": (
|
|
1649
|
+
catalog.resources_list_changed and catalog.resources_snapshot_loaded
|
|
1650
|
+
),
|
|
1651
|
+
"raw_tool_names": list(catalog.raw_tool_names),
|
|
1652
|
+
"raw_tool_count": len(catalog.raw_tool_names),
|
|
1653
|
+
"exposed_tool_names": exposed_tool_names,
|
|
1654
|
+
"exposed_tool_aliases": exposed_aliases,
|
|
1655
|
+
"exposed_tool_count": len(exposed_tool_names),
|
|
1656
|
+
"tools_list_changed": catalog.tools_list_changed,
|
|
1657
|
+
"tools_snapshot_stale": catalog.tools_list_changed,
|
|
1658
|
+
**(
|
|
1659
|
+
{"session_negotiated": catalog.session_negotiated}
|
|
1660
|
+
if catalog.server.transport == "http"
|
|
1661
|
+
else {}
|
|
1662
|
+
),
|
|
1663
|
+
}
|
|
1664
|
+
)
|
|
1665
|
+
# Keep one initialized, manager-owned connection for every active server.
|
|
1666
|
+
# This makes lifecycle status honest even when a server currently exposes
|
|
1667
|
+
# no tools/resources, and lets later prompt access share the same owner.
|
|
1668
|
+
clients_with_bindings[catalog.server.id] = catalog.client
|
|
1669
|
+
self._resource_records = tuple(resource_records)
|
|
1670
|
+
self._resource_lookup = {record.lookup_key: record for record in self._resource_records}
|
|
1671
|
+
self._resource_snapshotted_server_ids = {
|
|
1672
|
+
catalog.server.id
|
|
1673
|
+
for catalog in collected_catalogs
|
|
1674
|
+
if catalog.resources_snapshot_loaded
|
|
1675
|
+
}
|
|
1676
|
+
resource_tool_bindings = self._build_resource_tool_bindings()
|
|
1677
|
+
bindings.extend(resource_tool_bindings)
|
|
1678
|
+
self._tool_bindings = tuple(bindings)
|
|
1679
|
+
self._clients_by_server_id = clients_with_bindings
|
|
1680
|
+
prompt_server_catalogs = copy.deepcopy(
|
|
1681
|
+
self._catalog_snapshot.get("prompt_server_catalogs") or []
|
|
1682
|
+
)
|
|
1683
|
+
prompt_enabled_server_ids = list(
|
|
1684
|
+
self._catalog_snapshot.get("prompt_enabled_server_ids") or []
|
|
1685
|
+
)
|
|
1686
|
+
prompt_snapshotted_server_ids = list(
|
|
1687
|
+
self._catalog_snapshot.get("prompt_snapshotted_server_ids") or []
|
|
1688
|
+
)
|
|
1689
|
+
prompt_snapshot_complete = bool(self._catalog_snapshot.get("prompt_snapshot_complete"))
|
|
1690
|
+
prompt_snapshot_partial = bool(self._catalog_snapshot.get("prompt_snapshot_partial"))
|
|
1691
|
+
snapshotted_prompt_count = int(
|
|
1692
|
+
self._catalog_snapshot.get("snapshotted_prompt_count") or 0
|
|
1693
|
+
)
|
|
1694
|
+
manual_prompt_surface_enabled = bool(
|
|
1695
|
+
self._catalog_snapshot.get("manual_prompt_surface_enabled")
|
|
1696
|
+
)
|
|
1697
|
+
self._catalog_snapshot = {
|
|
1698
|
+
"catalog_initialized": True,
|
|
1699
|
+
"live_tool_runtime_enabled": self._live_runtime_enabled,
|
|
1700
|
+
"active_server_ids": [server.id for server in self._active_servers],
|
|
1701
|
+
"active_server_count": len(self._active_servers),
|
|
1702
|
+
"server_catalogs": server_catalogs,
|
|
1703
|
+
"exposed_tool_aliases": [binding.tool_alias for binding in self._tool_bindings],
|
|
1704
|
+
"exposed_tool_names": [binding.tool_name for binding in self._tool_bindings],
|
|
1705
|
+
"exposed_tool_count": len(self._tool_bindings),
|
|
1706
|
+
"snapshotted_resource_count": len(self._resource_records),
|
|
1707
|
+
"resource_tool_names": [binding.tool_alias for binding in resource_tool_bindings],
|
|
1708
|
+
"resource_tool_count": len(resource_tool_bindings),
|
|
1709
|
+
"prompt_enabled_server_ids": prompt_enabled_server_ids,
|
|
1710
|
+
"prompt_snapshotted_server_ids": prompt_snapshotted_server_ids,
|
|
1711
|
+
"prompt_snapshot_complete": prompt_snapshot_complete,
|
|
1712
|
+
"prompt_snapshot_partial": prompt_snapshot_partial,
|
|
1713
|
+
"tool_stale_server_ids": sorted(self._tool_stale_server_ids),
|
|
1714
|
+
"resource_stale_server_ids": sorted(self._resource_stale_server_ids),
|
|
1715
|
+
"prompt_stale_server_ids": sorted(self._prompt_stale_server_ids),
|
|
1716
|
+
"prompt_server_catalogs": prompt_server_catalogs,
|
|
1717
|
+
"snapshotted_prompt_count": snapshotted_prompt_count,
|
|
1718
|
+
"manual_prompt_surface_enabled": manual_prompt_surface_enabled,
|
|
1719
|
+
}
|
|
1720
|
+
for catalog in collected_catalogs:
|
|
1721
|
+
if catalog.tools_list_changed:
|
|
1722
|
+
self._tool_stale_server_ids.add(catalog.server.id)
|
|
1723
|
+
if catalog.resources_list_changed and catalog.resources_snapshot_loaded:
|
|
1724
|
+
self._resource_stale_server_ids.add(catalog.server.id)
|
|
1725
|
+
self._sync_stale_catalog_snapshot_metadata()
|
|
1726
|
+
except Exception as exc:
|
|
1727
|
+
self._snapshot_error = exc
|
|
1728
|
+
for catalog in collected_catalogs:
|
|
1729
|
+
catalog.client.close()
|
|
1730
|
+
self.close()
|
|
1731
|
+
raise
|
|
1732
|
+
|
|
1733
|
+
def _collect_server_catalogs(self) -> tuple[_CollectedServerCatalog, ...]:
|
|
1734
|
+
catalogs: list[_CollectedServerCatalog] = []
|
|
1735
|
+
try:
|
|
1736
|
+
for server in self._active_servers:
|
|
1737
|
+
if server.trust != "explicit":
|
|
1738
|
+
raise ConfigError(
|
|
1739
|
+
f"MCP server '{server.id}' uses unsupported trust mode '{server.trust}'. "
|
|
1740
|
+
"Live MCP tool exposure currently requires trust='explicit'."
|
|
1741
|
+
)
|
|
1742
|
+
leases = self._client_leases_by_server_id.get(server.id, [])
|
|
1743
|
+
existing_lease = leases[0] if leases else None
|
|
1744
|
+
raw_client: _McpLiveClient | None = None
|
|
1745
|
+
if (
|
|
1746
|
+
existing_lease is not None
|
|
1747
|
+
and existing_lease.enabled
|
|
1748
|
+
and not existing_lease.closed
|
|
1749
|
+
):
|
|
1750
|
+
client: _McpSessionClient = existing_lease
|
|
1751
|
+
else:
|
|
1752
|
+
raw_client = self._build_live_client(server)
|
|
1753
|
+
client = raw_client
|
|
1754
|
+
try:
|
|
1755
|
+
client.ensure_initialized()
|
|
1756
|
+
raw_tools: tuple[McpListedTool, ...] = ()
|
|
1757
|
+
filtered_tools: tuple[McpListedTool, ...] = ()
|
|
1758
|
+
raw_tool_names: tuple[str, ...] = ()
|
|
1759
|
+
if client.supports_tools:
|
|
1760
|
+
raw_tools = client.list_tools()
|
|
1761
|
+
filtered_tools, raw_tool_names = self._filter_server_tools(
|
|
1762
|
+
server=server,
|
|
1763
|
+
raw_tools=raw_tools,
|
|
1764
|
+
)
|
|
1765
|
+
elif server.allowed_tools or server.denied_tools:
|
|
1766
|
+
raise ConfigError(
|
|
1767
|
+
f"MCP server '{server.id}' does not advertise tools capability, but "
|
|
1768
|
+
"tool allow/deny policy is configured."
|
|
1769
|
+
)
|
|
1770
|
+
listed_resources: tuple[McpListedResource, ...] = ()
|
|
1771
|
+
resources_snapshot_loaded = resources_mode_enabled(
|
|
1772
|
+
resources_mode=server.resources_mode,
|
|
1773
|
+
runtime_kind=self.runtime_kind,
|
|
1774
|
+
)
|
|
1775
|
+
if resources_snapshot_loaded:
|
|
1776
|
+
listed_resources = client.list_resources()
|
|
1777
|
+
catalog_client = client
|
|
1778
|
+
if raw_client is not None and existing_lease is not None:
|
|
1779
|
+
if existing_lease.enabled:
|
|
1780
|
+
# Recover an enabled-but-disconnected lease without
|
|
1781
|
+
# invalidating objects that already reference it.
|
|
1782
|
+
existing_lease.replace_client(raw_client)
|
|
1783
|
+
else:
|
|
1784
|
+
# A pre-snapshot disable remains disabled. The
|
|
1785
|
+
# temporary connection existed only to freeze the
|
|
1786
|
+
# catalog and must not become reachable afterward.
|
|
1787
|
+
raw_client.close()
|
|
1788
|
+
raw_client = None
|
|
1789
|
+
catalog_client = existing_lease
|
|
1790
|
+
catalogs.append(
|
|
1791
|
+
_CollectedServerCatalog(
|
|
1792
|
+
server=server,
|
|
1793
|
+
client=catalog_client,
|
|
1794
|
+
raw_tools=raw_tools,
|
|
1795
|
+
filtered_tools=filtered_tools,
|
|
1796
|
+
raw_tool_names=raw_tool_names,
|
|
1797
|
+
listed_resources=listed_resources,
|
|
1798
|
+
resources_snapshot_loaded=resources_snapshot_loaded,
|
|
1799
|
+
tools_list_changed=client.tools_list_changed,
|
|
1800
|
+
resources_list_changed=client.resources_list_changed,
|
|
1801
|
+
roots_capability_enabled=client.roots_capability_enabled,
|
|
1802
|
+
resources_capability_advertised=client.supports_resources,
|
|
1803
|
+
session_negotiated=client.session_negotiated,
|
|
1804
|
+
)
|
|
1805
|
+
)
|
|
1806
|
+
except Exception:
|
|
1807
|
+
if raw_client is not None:
|
|
1808
|
+
raw_client.close()
|
|
1809
|
+
elif existing_lease is None:
|
|
1810
|
+
client.close()
|
|
1811
|
+
raise
|
|
1812
|
+
except Exception:
|
|
1813
|
+
for catalog in catalogs:
|
|
1814
|
+
catalog.client.close()
|
|
1815
|
+
raise
|
|
1816
|
+
return tuple(catalogs)
|
|
1817
|
+
|
|
1818
|
+
def _build_live_client(self, server: ResolvedMcpServer) -> _McpLiveClient:
|
|
1819
|
+
if server.transport == "stdio":
|
|
1820
|
+
return McpStdioClient(
|
|
1821
|
+
server=server,
|
|
1822
|
+
workspace_root=self.workspace_root,
|
|
1823
|
+
runtime_kind=self.runtime_kind,
|
|
1824
|
+
)
|
|
1825
|
+
if server.transport == "http":
|
|
1826
|
+
return McpHttpClient(
|
|
1827
|
+
server=server,
|
|
1828
|
+
workspace_root=self.workspace_root,
|
|
1829
|
+
runtime_kind=self.runtime_kind,
|
|
1830
|
+
)
|
|
1831
|
+
raise ConfigError(
|
|
1832
|
+
f"MCP server '{server.id}' uses unsupported transport '{server.transport}'."
|
|
1833
|
+
)
|
|
1834
|
+
|
|
1835
|
+
def _filter_server_tools(
|
|
1836
|
+
self,
|
|
1837
|
+
*,
|
|
1838
|
+
server: ResolvedMcpServer,
|
|
1839
|
+
raw_tools: tuple[McpListedTool, ...],
|
|
1840
|
+
) -> tuple[tuple[McpListedTool, ...], tuple[str, ...]]:
|
|
1841
|
+
raw_names = tuple(tool.name for tool in raw_tools)
|
|
1842
|
+
name_map: dict[str, McpListedTool] = {}
|
|
1843
|
+
for tool in raw_tools:
|
|
1844
|
+
name_key = _normalize_tool_name_key(tool.name)
|
|
1845
|
+
if name_key in name_map:
|
|
1846
|
+
raise ConfigError(
|
|
1847
|
+
f"MCP server '{server.id}' reported duplicate tool names that only differ by "
|
|
1848
|
+
f"case: '{name_map[name_key].name}' and '{tool.name}'."
|
|
1849
|
+
)
|
|
1850
|
+
name_map[name_key] = tool
|
|
1851
|
+
|
|
1852
|
+
if server.allowed_tools:
|
|
1853
|
+
missing_allowed = [
|
|
1854
|
+
name
|
|
1855
|
+
for name in server.allowed_tools
|
|
1856
|
+
if _normalize_tool_name_key(name) not in name_map
|
|
1857
|
+
]
|
|
1858
|
+
if missing_allowed:
|
|
1859
|
+
missing = ", ".join(missing_allowed)
|
|
1860
|
+
raise ConfigError(
|
|
1861
|
+
f"MCP server '{server.id}' did not report configured allowed_tools: {missing}"
|
|
1862
|
+
)
|
|
1863
|
+
if server.denied_tools:
|
|
1864
|
+
missing_denied = [
|
|
1865
|
+
name
|
|
1866
|
+
for name in server.denied_tools
|
|
1867
|
+
if _normalize_tool_name_key(name) not in name_map
|
|
1868
|
+
]
|
|
1869
|
+
if missing_denied:
|
|
1870
|
+
missing = ", ".join(missing_denied)
|
|
1871
|
+
raise ConfigError(
|
|
1872
|
+
f"MCP server '{server.id}' did not report configured denied_tools: {missing}"
|
|
1873
|
+
)
|
|
1874
|
+
|
|
1875
|
+
allowed_keys = {_normalize_tool_name_key(name) for name in server.allowed_tools}
|
|
1876
|
+
denied_keys = {_normalize_tool_name_key(name) for name in server.denied_tools}
|
|
1877
|
+
filtered: list[McpListedTool] = []
|
|
1878
|
+
for tool in raw_tools:
|
|
1879
|
+
tool_key = _normalize_tool_name_key(tool.name)
|
|
1880
|
+
if allowed_keys and tool_key not in allowed_keys:
|
|
1881
|
+
continue
|
|
1882
|
+
if tool_key in denied_keys:
|
|
1883
|
+
continue
|
|
1884
|
+
filtered.append(tool)
|
|
1885
|
+
return tuple(filtered), raw_names
|
|
1886
|
+
|
|
1887
|
+
def _assign_aliases(
|
|
1888
|
+
self,
|
|
1889
|
+
records: list[_FlatToolRecord],
|
|
1890
|
+
) -> dict[str, str]:
|
|
1891
|
+
reserved_names = {metadata.name for metadata in iter_builtin_tool_metadata()}
|
|
1892
|
+
groups: dict[str, list[_FlatToolRecord]] = {}
|
|
1893
|
+
for record in records:
|
|
1894
|
+
groups.setdefault(record.base_alias, []).append(record)
|
|
1895
|
+
|
|
1896
|
+
assigned: dict[str, str] = {}
|
|
1897
|
+
used_aliases: set[str] = set(reserved_names)
|
|
1898
|
+
for record in records:
|
|
1899
|
+
base_alias = record.base_alias
|
|
1900
|
+
group = groups[base_alias]
|
|
1901
|
+
alias = base_alias
|
|
1902
|
+
needs_hash = (
|
|
1903
|
+
len(base_alias) > _TOOL_ALIAS_MAX_LEN or len(group) > 1 or alias in used_aliases
|
|
1904
|
+
)
|
|
1905
|
+
if needs_hash:
|
|
1906
|
+
alias = _hashed_alias(base_alias, unique_key=record.unique_key)
|
|
1907
|
+
if alias in used_aliases:
|
|
1908
|
+
alias = _hashed_alias(
|
|
1909
|
+
f"{base_alias}__{len(used_aliases)}",
|
|
1910
|
+
unique_key=record.unique_key,
|
|
1911
|
+
)
|
|
1912
|
+
if alias in used_aliases:
|
|
1913
|
+
raise ConfigError(
|
|
1914
|
+
f"Failed to assign a collision-free MCP tool alias for "
|
|
1915
|
+
f"server '{record.server.id}' tool '{record.tool.name}'."
|
|
1916
|
+
)
|
|
1917
|
+
used_aliases.add(alias)
|
|
1918
|
+
assigned[record.unique_key] = alias
|
|
1919
|
+
return assigned
|
|
1920
|
+
|
|
1921
|
+
def close(self) -> None:
|
|
1922
|
+
with self._lifecycle_lock:
|
|
1923
|
+
if self._closed:
|
|
1924
|
+
return
|
|
1925
|
+
self._closed = True
|
|
1926
|
+
clients = list(self._clients_by_server_id.values())
|
|
1927
|
+
clients.extend(
|
|
1928
|
+
client
|
|
1929
|
+
for client in self._prompt_clients_by_server_id.values()
|
|
1930
|
+
if client not in clients
|
|
1931
|
+
)
|
|
1932
|
+
# Defensive cleanup: leases are the authoritative owners. Include
|
|
1933
|
+
# every lease even if a partially failed catalog build never placed
|
|
1934
|
+
# it in either lookup map.
|
|
1935
|
+
for leases in self._client_leases_by_server_id.values():
|
|
1936
|
+
clients.extend(client for client in leases if client not in clients)
|
|
1937
|
+
self._clients_by_server_id = {}
|
|
1938
|
+
self._prompt_clients_by_server_id = {}
|
|
1939
|
+
self._client_leases_by_server_id = {}
|
|
1940
|
+
self._disabled_server_ids = set(self._active_servers_by_id)
|
|
1941
|
+
for client in clients:
|
|
1942
|
+
client.close()
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
class ForgeTaskScopedMcpManager:
|
|
1946
|
+
def __init__(
|
|
1947
|
+
self,
|
|
1948
|
+
*,
|
|
1949
|
+
base_manager: McpManager | None,
|
|
1950
|
+
task_scope: ForgeTaskMcpScope | None,
|
|
1951
|
+
resolved_config: ResolvedMcpConfig | None = None,
|
|
1952
|
+
workspace_root: Path | None = None,
|
|
1953
|
+
runtime_kind: RuntimeKind | str | None = None,
|
|
1954
|
+
close_delegate: McpManager | None = None,
|
|
1955
|
+
) -> None:
|
|
1956
|
+
self._base_manager = base_manager
|
|
1957
|
+
self._close_delegate = close_delegate or base_manager
|
|
1958
|
+
self._task_scope = task_scope
|
|
1959
|
+
if base_manager is not None:
|
|
1960
|
+
self._resolved_config = base_manager.resolved_config
|
|
1961
|
+
self._resolved_servers = base_manager.resolved_servers
|
|
1962
|
+
self._workspace_root = base_manager.workspace_root
|
|
1963
|
+
self._runtime_kind = base_manager.runtime_kind
|
|
1964
|
+
else:
|
|
1965
|
+
if resolved_config is None or workspace_root is None or runtime_kind is None:
|
|
1966
|
+
raise RuntimeError(
|
|
1967
|
+
"ForgeTaskScopedMcpManager requires resolved_config, workspace_root, "
|
|
1968
|
+
"and runtime_kind when no live base_manager is provided."
|
|
1969
|
+
)
|
|
1970
|
+
self._resolved_config = resolved_config
|
|
1971
|
+
self._resolved_servers = tuple(resolved_config.servers)
|
|
1972
|
+
self._workspace_root = workspace_root.resolve()
|
|
1973
|
+
self._runtime_kind = normalize_runtime_kind(runtime_kind)
|
|
1974
|
+
self._filtered_bindings: tuple[_McpManagerToolBinding, ...] | None = None
|
|
1975
|
+
self._scope_metadata: dict[str, Any] | None = None
|
|
1976
|
+
self._closed = False
|
|
1977
|
+
|
|
1978
|
+
@classmethod
|
|
1979
|
+
def without_live_bootstrap(
|
|
1980
|
+
cls,
|
|
1981
|
+
*,
|
|
1982
|
+
resolved_config: ResolvedMcpConfig,
|
|
1983
|
+
workspace_root: Path,
|
|
1984
|
+
runtime_kind: RuntimeKind | str,
|
|
1985
|
+
task_scope: ForgeTaskMcpScope | None,
|
|
1986
|
+
close_delegate: McpManager | None = None,
|
|
1987
|
+
) -> ForgeTaskScopedMcpManager:
|
|
1988
|
+
return cls(
|
|
1989
|
+
base_manager=None,
|
|
1990
|
+
task_scope=task_scope,
|
|
1991
|
+
resolved_config=resolved_config,
|
|
1992
|
+
workspace_root=workspace_root,
|
|
1993
|
+
runtime_kind=runtime_kind,
|
|
1994
|
+
close_delegate=close_delegate,
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
@property
|
|
1998
|
+
def resolved_config(self) -> ResolvedMcpConfig:
|
|
1999
|
+
return self._resolved_config
|
|
2000
|
+
|
|
2001
|
+
@property
|
|
2002
|
+
def resolved_servers(self) -> tuple[ResolvedMcpServer, ...]:
|
|
2003
|
+
return self._resolved_servers
|
|
2004
|
+
|
|
2005
|
+
@property
|
|
2006
|
+
def active_servers(self) -> tuple[ResolvedMcpServer, ...]:
|
|
2007
|
+
if self._base_manager is None:
|
|
2008
|
+
return ()
|
|
2009
|
+
return self._base_manager.active_servers
|
|
2010
|
+
|
|
2011
|
+
@property
|
|
2012
|
+
def workspace_root(self) -> Path:
|
|
2013
|
+
return self._workspace_root
|
|
2014
|
+
|
|
2015
|
+
@property
|
|
2016
|
+
def runtime_kind(self) -> RuntimeKind:
|
|
2017
|
+
return self._runtime_kind
|
|
2018
|
+
|
|
2019
|
+
@property
|
|
2020
|
+
def closed(self) -> bool:
|
|
2021
|
+
if self._close_delegate is not None:
|
|
2022
|
+
return self._close_delegate.closed
|
|
2023
|
+
return self._closed
|
|
2024
|
+
|
|
2025
|
+
@property
|
|
2026
|
+
def tool_bindings(self) -> tuple[_McpManagerToolBinding, ...]:
|
|
2027
|
+
self._ensure_filtered_bindings()
|
|
2028
|
+
assert self._filtered_bindings is not None
|
|
2029
|
+
return self._filtered_bindings
|
|
2030
|
+
|
|
2031
|
+
def startup_metadata(self) -> dict[str, Any]:
|
|
2032
|
+
if self._base_manager is not None:
|
|
2033
|
+
metadata = copy.deepcopy(self._base_manager.startup_metadata())
|
|
2034
|
+
else:
|
|
2035
|
+
metadata = {
|
|
2036
|
+
"config_present": self._resolved_config.has_any_config,
|
|
2037
|
+
"user_config_present": self._resolved_config.user_config_present,
|
|
2038
|
+
"project_config_present": self._resolved_config.project_config_present,
|
|
2039
|
+
"resolved_server_count": len(self._resolved_servers),
|
|
2040
|
+
"resolved_server_ids": [server.id for server in self._resolved_servers],
|
|
2041
|
+
"active_server_count": 0,
|
|
2042
|
+
"active_server_ids": [],
|
|
2043
|
+
"live_tool_runtime_enabled": False,
|
|
2044
|
+
}
|
|
2045
|
+
metadata.update(self._scope_metadata_payload())
|
|
2046
|
+
return metadata
|
|
2047
|
+
|
|
2048
|
+
def catalog_snapshot_metadata(self) -> dict[str, Any]:
|
|
2049
|
+
if self._base_manager is not None:
|
|
2050
|
+
snapshot = self._base_manager.catalog_snapshot_metadata()
|
|
2051
|
+
else:
|
|
2052
|
+
snapshot = {
|
|
2053
|
+
"catalog_initialized": True,
|
|
2054
|
+
"live_tool_runtime_enabled": False,
|
|
2055
|
+
"active_server_ids": [],
|
|
2056
|
+
"active_server_count": 0,
|
|
2057
|
+
"server_catalogs": [],
|
|
2058
|
+
"exposed_tool_aliases": [],
|
|
2059
|
+
"exposed_tool_names": [],
|
|
2060
|
+
"exposed_tool_count": 0,
|
|
2061
|
+
"snapshotted_resource_count": 0,
|
|
2062
|
+
"resource_tool_names": [],
|
|
2063
|
+
"resource_tool_count": 0,
|
|
2064
|
+
}
|
|
2065
|
+
snapshot["forge_task_mcp_scope"] = self._scope_metadata_payload()
|
|
2066
|
+
self._apply_filtered_tool_snapshot_metadata(snapshot)
|
|
2067
|
+
return snapshot
|
|
2068
|
+
|
|
2069
|
+
def execution_context_summary(self) -> dict[str, Any]:
|
|
2070
|
+
self._ensure_filtered_bindings()
|
|
2071
|
+
summary = {
|
|
2072
|
+
"active_server_ids": [],
|
|
2073
|
+
"servers": [],
|
|
2074
|
+
"task_scope": self._task_scope_payload(),
|
|
2075
|
+
}
|
|
2076
|
+
if self._base_manager is None:
|
|
2077
|
+
return summary
|
|
2078
|
+
assert self._filtered_bindings is not None
|
|
2079
|
+
filtered_tool_names: dict[str, list[str]] = {}
|
|
2080
|
+
for binding in self._filtered_bindings:
|
|
2081
|
+
if not isinstance(binding, McpToolBinding):
|
|
2082
|
+
continue
|
|
2083
|
+
filtered_tool_names.setdefault(binding.server_id, []).append(binding.tool_name)
|
|
2084
|
+
base_summary = self._base_manager.execution_context_summary()
|
|
2085
|
+
filtered_servers: list[dict[str, Any]] = []
|
|
2086
|
+
allow_resources = bool(self._task_scope and self._task_scope.allow_resources)
|
|
2087
|
+
for raw_entry in base_summary.get("servers") or []:
|
|
2088
|
+
if not isinstance(raw_entry, dict):
|
|
2089
|
+
continue
|
|
2090
|
+
server_id = str(raw_entry.get("server_id") or "").strip()
|
|
2091
|
+
if not server_id:
|
|
2092
|
+
continue
|
|
2093
|
+
tool_names = list(filtered_tool_names.get(server_id) or [])
|
|
2094
|
+
resources_available = bool(allow_resources and raw_entry.get("resources_available"))
|
|
2095
|
+
if not tool_names and not resources_available:
|
|
2096
|
+
continue
|
|
2097
|
+
filtered_servers.append(
|
|
2098
|
+
{
|
|
2099
|
+
"server_id": server_id,
|
|
2100
|
+
"tool_names": tool_names,
|
|
2101
|
+
"resources_available": resources_available,
|
|
2102
|
+
}
|
|
2103
|
+
)
|
|
2104
|
+
summary["active_server_ids"] = [entry["server_id"] for entry in filtered_servers]
|
|
2105
|
+
summary["servers"] = filtered_servers
|
|
2106
|
+
return summary
|
|
2107
|
+
|
|
2108
|
+
def close(self) -> None:
|
|
2109
|
+
if self._close_delegate is not None:
|
|
2110
|
+
self._close_delegate.close()
|
|
2111
|
+
return
|
|
2112
|
+
self._closed = True
|
|
2113
|
+
|
|
2114
|
+
def _scope_metadata_payload(self) -> dict[str, Any]:
|
|
2115
|
+
self._ensure_filtered_bindings()
|
|
2116
|
+
assert self._scope_metadata is not None
|
|
2117
|
+
return copy.deepcopy(self._scope_metadata)
|
|
2118
|
+
|
|
2119
|
+
def _task_scope_payload(self) -> dict[str, Any]:
|
|
2120
|
+
return {
|
|
2121
|
+
"present": self._task_scope is not None,
|
|
2122
|
+
"allow_resources": bool(self._task_scope and self._task_scope.allow_resources),
|
|
2123
|
+
"allowed_tools": [
|
|
2124
|
+
{
|
|
2125
|
+
"server_id": item.server_id,
|
|
2126
|
+
"tool_name": item.tool_name,
|
|
2127
|
+
}
|
|
2128
|
+
for item in (self._task_scope.allowed_tools if self._task_scope is not None else ())
|
|
2129
|
+
],
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
def _ensure_filtered_bindings(self) -> None:
|
|
2133
|
+
if self._filtered_bindings is not None:
|
|
2134
|
+
return
|
|
2135
|
+
if self._base_manager is None:
|
|
2136
|
+
allow_resources = bool(self._task_scope and self._task_scope.allow_resources)
|
|
2137
|
+
allowed_live_tool_count = (
|
|
2138
|
+
len(self._task_scope.allowed_tools) if self._task_scope is not None else 0
|
|
2139
|
+
)
|
|
2140
|
+
self._filtered_bindings = ()
|
|
2141
|
+
self._scope_metadata = {
|
|
2142
|
+
"forge_task_mcp_scope_present": self._task_scope is not None,
|
|
2143
|
+
"forge_task_resources_allowed": allow_resources,
|
|
2144
|
+
"forge_task_allowed_live_tool_count": allowed_live_tool_count,
|
|
2145
|
+
"forge_task_filtered_live_tool_count": 0,
|
|
2146
|
+
"forge_task_filtered_resource_tool_count": 0,
|
|
2147
|
+
"forge_task_filtered_tool_count": 0,
|
|
2148
|
+
"forge_task_live_bootstrap_skipped": True,
|
|
2149
|
+
}
|
|
2150
|
+
return
|
|
2151
|
+
base_bindings = self._base_manager.tool_bindings
|
|
2152
|
+
allowed_live_pairs = {
|
|
2153
|
+
(item.server_id, item.tool_name)
|
|
2154
|
+
for item in (self._task_scope.allowed_tools if self._task_scope is not None else ())
|
|
2155
|
+
}
|
|
2156
|
+
available_live_pairs = {
|
|
2157
|
+
(binding.server_id, binding.tool_name): binding
|
|
2158
|
+
for binding in base_bindings
|
|
2159
|
+
if isinstance(binding, McpToolBinding)
|
|
2160
|
+
}
|
|
2161
|
+
unknown_allowed_pairs = [
|
|
2162
|
+
f"{server_id}/{tool_name}"
|
|
2163
|
+
for server_id, tool_name in allowed_live_pairs
|
|
2164
|
+
if (server_id, tool_name) not in available_live_pairs
|
|
2165
|
+
]
|
|
2166
|
+
if unknown_allowed_pairs:
|
|
2167
|
+
unknown_label = ", ".join(sorted(unknown_allowed_pairs))
|
|
2168
|
+
raise ConfigError(
|
|
2169
|
+
"Forge task mcp_scope references MCP tools that are not present in the frozen "
|
|
2170
|
+
f"session catalog: {unknown_label}"
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
allow_resources = bool(self._task_scope and self._task_scope.allow_resources)
|
|
2174
|
+
filtered_bindings: list[_McpManagerToolBinding] = []
|
|
2175
|
+
filtered_live_tool_count = 0
|
|
2176
|
+
filtered_resource_tool_count = 0
|
|
2177
|
+
for binding in base_bindings:
|
|
2178
|
+
if isinstance(binding, McpToolBinding):
|
|
2179
|
+
if (binding.server_id, binding.tool_name) not in allowed_live_pairs:
|
|
2180
|
+
continue
|
|
2181
|
+
filtered_live_tool_count += 1
|
|
2182
|
+
filtered_bindings.append(binding)
|
|
2183
|
+
continue
|
|
2184
|
+
if binding.tool_alias in _RESOURCE_TOOL_ALIASES and allow_resources:
|
|
2185
|
+
filtered_resource_tool_count += 1
|
|
2186
|
+
filtered_bindings.append(binding)
|
|
2187
|
+
|
|
2188
|
+
self._filtered_bindings = tuple(filtered_bindings)
|
|
2189
|
+
self._scope_metadata = {
|
|
2190
|
+
"forge_task_mcp_scope_present": self._task_scope is not None,
|
|
2191
|
+
"forge_task_resources_allowed": allow_resources,
|
|
2192
|
+
"forge_task_allowed_live_tool_count": len(allowed_live_pairs),
|
|
2193
|
+
"forge_task_filtered_live_tool_count": filtered_live_tool_count,
|
|
2194
|
+
"forge_task_filtered_resource_tool_count": filtered_resource_tool_count,
|
|
2195
|
+
"forge_task_filtered_tool_count": len(filtered_bindings),
|
|
2196
|
+
"forge_task_live_bootstrap_skipped": False,
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
def _apply_filtered_tool_snapshot_metadata(self, snapshot: dict[str, Any]) -> None:
|
|
2200
|
+
self._ensure_filtered_bindings()
|
|
2201
|
+
filtered_bindings = tuple(self._filtered_bindings or ())
|
|
2202
|
+
snapshot["exposed_tool_aliases"] = [binding.tool_alias for binding in filtered_bindings]
|
|
2203
|
+
snapshot["exposed_tool_names"] = [binding.tool_name for binding in filtered_bindings]
|
|
2204
|
+
snapshot["exposed_tool_count"] = len(filtered_bindings)
|
|
2205
|
+
resource_aliases = [
|
|
2206
|
+
binding.tool_alias
|
|
2207
|
+
for binding in filtered_bindings
|
|
2208
|
+
if isinstance(binding, McpHostToolBinding)
|
|
2209
|
+
and binding.tool_alias in _RESOURCE_TOOL_ALIASES
|
|
2210
|
+
]
|
|
2211
|
+
snapshot["resource_tool_names"] = resource_aliases
|
|
2212
|
+
snapshot["resource_tool_count"] = len(resource_aliases)
|
|
2213
|
+
|
|
2214
|
+
live_bindings_by_server_id: dict[str, list[McpToolBinding]] = {}
|
|
2215
|
+
for binding in filtered_bindings:
|
|
2216
|
+
if isinstance(binding, McpToolBinding):
|
|
2217
|
+
live_bindings_by_server_id.setdefault(binding.server_id, []).append(binding)
|
|
2218
|
+
|
|
2219
|
+
server_catalogs = snapshot.get("server_catalogs")
|
|
2220
|
+
if not isinstance(server_catalogs, list):
|
|
2221
|
+
return
|
|
2222
|
+
for entry in server_catalogs:
|
|
2223
|
+
if not isinstance(entry, dict):
|
|
2224
|
+
continue
|
|
2225
|
+
server_id = str(entry.get("server_id") or "").strip()
|
|
2226
|
+
live_bindings = live_bindings_by_server_id.get(server_id) or []
|
|
2227
|
+
entry["exposed_tool_aliases"] = [binding.tool_alias for binding in live_bindings]
|
|
2228
|
+
entry["exposed_tool_names"] = [binding.tool_name for binding in live_bindings]
|
|
2229
|
+
entry["exposed_tool_count"] = len(live_bindings)
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def create_mcp_manager(
|
|
2233
|
+
*,
|
|
2234
|
+
workspace_root: Path,
|
|
2235
|
+
runtime_kind: RuntimeKind | str,
|
|
2236
|
+
session_id: str | None = None,
|
|
2237
|
+
) -> McpManager:
|
|
2238
|
+
resolved_kind = normalize_runtime_kind(runtime_kind)
|
|
2239
|
+
resolved_config = load_resolved_mcp_config(
|
|
2240
|
+
workspace_root=workspace_root,
|
|
2241
|
+
)
|
|
2242
|
+
return McpManager(
|
|
2243
|
+
resolved_config=resolved_config,
|
|
2244
|
+
workspace_root=workspace_root,
|
|
2245
|
+
runtime_kind=resolved_kind,
|
|
2246
|
+
session_id=session_id,
|
|
2247
|
+
)
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
def create_forge_task_scoped_mcp_manager(
|
|
2251
|
+
*,
|
|
2252
|
+
workspace_root: Path,
|
|
2253
|
+
session_id: str | None = None,
|
|
2254
|
+
task_scope: ForgeTaskMcpScope | None,
|
|
2255
|
+
) -> ForgeTaskScopedMcpManager:
|
|
2256
|
+
resolved_kind = RuntimeKind.FORGE_EXEC
|
|
2257
|
+
if task_scope is None or task_scope.is_empty:
|
|
2258
|
+
resolved_workspace_root = workspace_root.resolve()
|
|
2259
|
+
return ForgeTaskScopedMcpManager.without_live_bootstrap(
|
|
2260
|
+
resolved_config=ResolvedMcpConfig(
|
|
2261
|
+
workspace_root=resolved_workspace_root,
|
|
2262
|
+
user_config_path=user_mcp_config_path(),
|
|
2263
|
+
project_config_path=project_mcp_config_path(resolved_workspace_root),
|
|
2264
|
+
user_config_present=False,
|
|
2265
|
+
project_config_present=False,
|
|
2266
|
+
servers=(),
|
|
2267
|
+
),
|
|
2268
|
+
workspace_root=resolved_workspace_root,
|
|
2269
|
+
runtime_kind=resolved_kind,
|
|
2270
|
+
task_scope=task_scope,
|
|
2271
|
+
)
|
|
2272
|
+
resolved_config = load_resolved_mcp_config(
|
|
2273
|
+
workspace_root=workspace_root,
|
|
2274
|
+
)
|
|
2275
|
+
manager = McpManager(
|
|
2276
|
+
resolved_config=resolved_config,
|
|
2277
|
+
workspace_root=workspace_root,
|
|
2278
|
+
runtime_kind=resolved_kind,
|
|
2279
|
+
session_id=session_id,
|
|
2280
|
+
)
|
|
2281
|
+
return manager.scope_for_forge_task(task_scope=task_scope)
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
def build_mcp_execution_context_summary(
|
|
2285
|
+
*,
|
|
2286
|
+
workspace_root: Path,
|
|
2287
|
+
runtime_kind: RuntimeKind | str,
|
|
2288
|
+
) -> dict[str, Any] | None:
|
|
2289
|
+
try:
|
|
2290
|
+
resolved_kind = normalize_runtime_kind(runtime_kind)
|
|
2291
|
+
resolved_config = load_resolved_mcp_config(workspace_root=workspace_root)
|
|
2292
|
+
active_servers = resolved_config.active_servers_for(resolved_kind)
|
|
2293
|
+
return {
|
|
2294
|
+
"active_server_ids": [server.id for server in active_servers],
|
|
2295
|
+
"servers": [
|
|
2296
|
+
{
|
|
2297
|
+
"server_id": server.id,
|
|
2298
|
+
"tool_names": [name for name in server.allowed_tools if str(name).strip()],
|
|
2299
|
+
"resources_available": resources_mode_enabled(
|
|
2300
|
+
server.resources_mode,
|
|
2301
|
+
runtime_kind=resolved_kind,
|
|
2302
|
+
),
|
|
2303
|
+
}
|
|
2304
|
+
for server in active_servers
|
|
2305
|
+
],
|
|
2306
|
+
}
|
|
2307
|
+
except Exception:
|
|
2308
|
+
return None
|