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,1267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import select
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import weakref
|
|
11
|
+
from collections import deque
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .errors import McpProcessError, McpProtocolError, McpTimeoutError
|
|
18
|
+
from .jsonrpc import (
|
|
19
|
+
JsonRpcIdGenerator,
|
|
20
|
+
JsonRpcNotification,
|
|
21
|
+
JsonRpcProtocolError,
|
|
22
|
+
JsonRpcRequest,
|
|
23
|
+
JsonRpcResponse,
|
|
24
|
+
build_jsonrpc_error_response,
|
|
25
|
+
build_jsonrpc_notification,
|
|
26
|
+
build_jsonrpc_request,
|
|
27
|
+
build_jsonrpc_result_response,
|
|
28
|
+
encode_jsonrpc_message,
|
|
29
|
+
parse_jsonrpc_line,
|
|
30
|
+
)
|
|
31
|
+
from .models import ResolvedMcpServer
|
|
32
|
+
from .server_requests import (
|
|
33
|
+
McpServerRequestContext,
|
|
34
|
+
McpServerRequestHandler,
|
|
35
|
+
McpServerRequestHandlerError,
|
|
36
|
+
McpUnsupportedServerRequestError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_STDERR_LINE_LIMIT = 80
|
|
40
|
+
_STDERR_CHAR_LIMIT = 4000
|
|
41
|
+
_SHUTDOWN_WAIT_S = 0.75
|
|
42
|
+
_TERMINATE_WAIT_S = 1.0
|
|
43
|
+
_UNSUPPORTED_CLIENT_REQUEST_CODE = -32601
|
|
44
|
+
_STDOUT_READ_CHUNK_SIZE = 4096
|
|
45
|
+
_STDERR_READ_CHUNK_SIZE = 4096
|
|
46
|
+
_STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S = 0.25
|
|
47
|
+
_STDOUT_RESPONSE_QUIESCENCE_WAIT_S = 0.1
|
|
48
|
+
_STDOUT_RESPONSE_FOLLOW_UP_BUDGET_S = _STDOUT_RESPONSE_QUIESCENCE_WAIT_S * 2
|
|
49
|
+
_STDERR_QUIESCENCE_WAIT_S = 0.75
|
|
50
|
+
_FD_READY_POLL_INTERVAL_S = 0.01
|
|
51
|
+
_READER_THREAD_JOIN_WAIT_S = 1.0
|
|
52
|
+
_RETIRED_REQUEST_STATE_RETENTION_LIMIT = 1024
|
|
53
|
+
_REQUEST_STATE_AWAITING_RESPONSE = "awaiting_response"
|
|
54
|
+
_REQUEST_STATE_AWAITING_FOLLOW_UP = "awaiting_follow_up"
|
|
55
|
+
_REQUEST_STATE_COMPLETED = "completed"
|
|
56
|
+
_REQUEST_STATE_RESPONSE_TIMED_OUT = "response_timed_out"
|
|
57
|
+
_REQUEST_STATE_FOLLOW_UP_TIMED_OUT = "follow_up_timed_out"
|
|
58
|
+
_REQUEST_STATE_CANCELLED = "cancelled"
|
|
59
|
+
_REQUEST_STATES_DISCARD_LATE_RESPONSES = frozenset(
|
|
60
|
+
{
|
|
61
|
+
_REQUEST_STATE_RESPONSE_TIMED_OUT,
|
|
62
|
+
_REQUEST_STATE_FOLLOW_UP_TIMED_OUT,
|
|
63
|
+
_REQUEST_STATE_CANCELLED,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
_CONSERVATIVE_ENV_KEYS = (
|
|
67
|
+
"PATH",
|
|
68
|
+
"HOME",
|
|
69
|
+
"USERPROFILE",
|
|
70
|
+
"HOMEDRIVE",
|
|
71
|
+
"HOMEPATH",
|
|
72
|
+
"TMPDIR",
|
|
73
|
+
"TEMP",
|
|
74
|
+
"TMP",
|
|
75
|
+
"LANG",
|
|
76
|
+
"LC_ALL",
|
|
77
|
+
"TERM",
|
|
78
|
+
"SystemRoot",
|
|
79
|
+
"SYSTEMROOT",
|
|
80
|
+
"ComSpec",
|
|
81
|
+
"COMSPEC",
|
|
82
|
+
"PATHEXT",
|
|
83
|
+
"WINDIR",
|
|
84
|
+
)
|
|
85
|
+
_INTERNAL_CLIENT_REQUEST_CODE = -32603
|
|
86
|
+
_INVALID_STDOUT_UTF8_MESSAGE = "received invalid UTF-8 on stdio stdout"
|
|
87
|
+
_LIVE_STDIO_TRANSPORTS: weakref.WeakSet[Any] = weakref.WeakSet()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class McpStdioTransportError(McpProcessError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class McpStdioTransportTimeoutError(McpStdioTransportError, McpTimeoutError):
|
|
95
|
+
error_code = "mcp_stdio_transport_timeout"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class McpStdioTransportProtocolError(McpStdioTransportError, McpProtocolError):
|
|
99
|
+
error_code = "mcp_stdio_transport_protocol_error"
|
|
100
|
+
retryable = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _decode_stdout_protocol_line(line_bytes: bytes) -> str:
|
|
104
|
+
try:
|
|
105
|
+
return line_bytes.decode("utf-8").rstrip("\r")
|
|
106
|
+
except UnicodeDecodeError as exc:
|
|
107
|
+
raise McpStdioTransportProtocolError(_INVALID_STDOUT_UTF8_MESSAGE) from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class _QueuedResponse:
|
|
112
|
+
response: JsonRpcResponse
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class _DeferredResponse:
|
|
117
|
+
response: JsonRpcResponse
|
|
118
|
+
follow_up_deadline_monotonic: float
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class _ObservedResponse:
|
|
123
|
+
follow_up_deadline_monotonic: float
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class _DeferredResponseFollowUpTimeout:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class _PendingRequest:
|
|
133
|
+
response_queue: queue.Queue[Any]
|
|
134
|
+
follow_up_budget_s: float
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class _FatalTransportState:
|
|
139
|
+
message: str
|
|
140
|
+
exc_type: type[McpStdioTransportError]
|
|
141
|
+
stderr_append_sequence: int
|
|
142
|
+
stderr_read_sequence: int
|
|
143
|
+
stderr_last_activity_monotonic: float
|
|
144
|
+
fatal_monotonic: float
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_stdio_subprocess_env(
|
|
148
|
+
*,
|
|
149
|
+
overlay_env: Mapping[str, str],
|
|
150
|
+
host_env: Mapping[str, str] | None = None,
|
|
151
|
+
) -> dict[str, str]:
|
|
152
|
+
source_env = os.environ if host_env is None else host_env
|
|
153
|
+
env: dict[str, str] = {}
|
|
154
|
+
for key in _CONSERVATIVE_ENV_KEYS:
|
|
155
|
+
value = source_env.get(key)
|
|
156
|
+
if value:
|
|
157
|
+
env[key] = value
|
|
158
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
159
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
160
|
+
for key, value in overlay_env.items():
|
|
161
|
+
env[str(key)] = str(value)
|
|
162
|
+
return env
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class McpStdioTransport:
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
server: ResolvedMcpServer,
|
|
170
|
+
workspace_root: Path,
|
|
171
|
+
host_env: Mapping[str, str] | None = None,
|
|
172
|
+
server_request_context: McpServerRequestContext | None = None,
|
|
173
|
+
server_request_handler: McpServerRequestHandler | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
if server.transport != "stdio":
|
|
176
|
+
raise McpStdioTransportError(f"MCP server '{server.id}' is not a stdio transport.")
|
|
177
|
+
if server.command is None:
|
|
178
|
+
raise McpStdioTransportError(f"MCP server '{server.id}' is missing a stdio command.")
|
|
179
|
+
self.server = server
|
|
180
|
+
self.workspace_root = workspace_root.resolve()
|
|
181
|
+
self.host_env = os.environ if host_env is None else host_env
|
|
182
|
+
self._server_request_context = server_request_context
|
|
183
|
+
self._server_request_handler = server_request_handler
|
|
184
|
+
self.spawn_env = build_stdio_subprocess_env(
|
|
185
|
+
overlay_env=server.env,
|
|
186
|
+
host_env=self.host_env,
|
|
187
|
+
)
|
|
188
|
+
self._id_generator = JsonRpcIdGenerator()
|
|
189
|
+
self._notifications: queue.Queue[JsonRpcNotification] = queue.Queue()
|
|
190
|
+
self._pending: dict[int | str, _PendingRequest] = {}
|
|
191
|
+
self._pending_lock = threading.Lock()
|
|
192
|
+
self._request_states: dict[int | str, str] = {}
|
|
193
|
+
self._retired_request_state_ids: deque[int | str] = deque()
|
|
194
|
+
self._stderr_tail: deque[str] = deque(maxlen=_STDERR_LINE_LIMIT)
|
|
195
|
+
self._stderr_state_condition = threading.Condition()
|
|
196
|
+
self._stderr_append_sequence = 0
|
|
197
|
+
self._stderr_read_sequence = 0
|
|
198
|
+
self._stderr_quiescence_generation = 0
|
|
199
|
+
self._stderr_last_activity_monotonic = time.monotonic()
|
|
200
|
+
self._stderr_reader_idle = False
|
|
201
|
+
self._stderr_reader_closed = False
|
|
202
|
+
self._stdout_state_condition = threading.Condition()
|
|
203
|
+
self._stdout_quiescence_generation = 0
|
|
204
|
+
self._stdout_read_sequence = 0
|
|
205
|
+
self._stdout_last_activity_monotonic = time.monotonic()
|
|
206
|
+
self._stdout_reader_idle = False
|
|
207
|
+
self._stdout_reader_closed = False
|
|
208
|
+
self._stdout_thread: threading.Thread | None = None
|
|
209
|
+
self._stderr_thread: threading.Thread | None = None
|
|
210
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
211
|
+
self._write_lock = threading.Lock()
|
|
212
|
+
self._started = False
|
|
213
|
+
self._closed = False
|
|
214
|
+
self._closing = False
|
|
215
|
+
self._fatal_state: _FatalTransportState | None = None
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def process(self) -> subprocess.Popen[bytes] | None:
|
|
219
|
+
return self._process
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def closed(self) -> bool:
|
|
223
|
+
return self._closed
|
|
224
|
+
|
|
225
|
+
def diagnostic_snapshot(self) -> dict[str, Any]:
|
|
226
|
+
process = self._process
|
|
227
|
+
return {
|
|
228
|
+
"transport_id": id(self),
|
|
229
|
+
"server_id": self.server.id,
|
|
230
|
+
"command": self.server.command,
|
|
231
|
+
"pid": process.pid if process is not None else None,
|
|
232
|
+
"returncode": process.poll() if process is not None else None,
|
|
233
|
+
"started": self._started,
|
|
234
|
+
"closed": self._closed,
|
|
235
|
+
"closing": self._closing,
|
|
236
|
+
"stdout_thread_alive": bool(
|
|
237
|
+
self._stdout_thread is not None and self._stdout_thread.is_alive()
|
|
238
|
+
),
|
|
239
|
+
"stderr_thread_alive": bool(
|
|
240
|
+
self._stderr_thread is not None and self._stderr_thread.is_alive()
|
|
241
|
+
),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def _has_live_process_or_threads(self) -> bool:
|
|
245
|
+
process = self._process
|
|
246
|
+
return bool(
|
|
247
|
+
(process is not None and process.poll() is None)
|
|
248
|
+
or (self._stdout_thread is not None and self._stdout_thread.is_alive())
|
|
249
|
+
or (self._stderr_thread is not None and self._stderr_thread.is_alive())
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def stderr_tail(self) -> str:
|
|
253
|
+
with self._stderr_state_condition:
|
|
254
|
+
joined = "\n".join(self._stderr_tail)
|
|
255
|
+
if len(joined) <= _STDERR_CHAR_LIMIT:
|
|
256
|
+
return joined
|
|
257
|
+
return joined[-_STDERR_CHAR_LIMIT:]
|
|
258
|
+
|
|
259
|
+
def _context(self) -> str:
|
|
260
|
+
return f"server '{self.server.id}' (command '{self.server.command}')"
|
|
261
|
+
|
|
262
|
+
def _format_error_message(self, message: str, *, stderr_tail: str = "") -> str:
|
|
263
|
+
full_message = f"MCP stdio {self._context()}: {message}"
|
|
264
|
+
cleaned_tail = str(stderr_tail or "").strip()
|
|
265
|
+
if cleaned_tail:
|
|
266
|
+
full_message += f"\nstderr tail:\n{cleaned_tail}"
|
|
267
|
+
return full_message
|
|
268
|
+
|
|
269
|
+
def _build_error(
|
|
270
|
+
self,
|
|
271
|
+
message: str,
|
|
272
|
+
*,
|
|
273
|
+
exc_type: type[McpStdioTransportError] = McpStdioTransportError,
|
|
274
|
+
) -> McpStdioTransportError:
|
|
275
|
+
return exc_type(self._format_error_message(message, stderr_tail=self.stderr_tail().strip()))
|
|
276
|
+
|
|
277
|
+
def _build_fatal_state(
|
|
278
|
+
self,
|
|
279
|
+
message: str,
|
|
280
|
+
*,
|
|
281
|
+
exc_type: type[McpStdioTransportError] = McpStdioTransportError,
|
|
282
|
+
) -> _FatalTransportState:
|
|
283
|
+
with self._stderr_state_condition:
|
|
284
|
+
stderr_sequence = self._stderr_append_sequence
|
|
285
|
+
stderr_read_sequence = self._stderr_read_sequence
|
|
286
|
+
stderr_last_activity_monotonic = self._stderr_last_activity_monotonic
|
|
287
|
+
return _FatalTransportState(
|
|
288
|
+
message=message,
|
|
289
|
+
exc_type=exc_type,
|
|
290
|
+
stderr_append_sequence=stderr_sequence,
|
|
291
|
+
stderr_read_sequence=stderr_read_sequence,
|
|
292
|
+
stderr_last_activity_monotonic=stderr_last_activity_monotonic,
|
|
293
|
+
fatal_monotonic=time.monotonic(),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _set_fatal_error(self, fatal_state: _FatalTransportState) -> None:
|
|
297
|
+
with self._pending_lock:
|
|
298
|
+
if self._fatal_state is not None:
|
|
299
|
+
return
|
|
300
|
+
self._fatal_state = fatal_state
|
|
301
|
+
pending = [entry.response_queue for entry in self._pending.values()]
|
|
302
|
+
self._pending.clear()
|
|
303
|
+
with self._stdout_state_condition:
|
|
304
|
+
self._stdout_state_condition.notify_all()
|
|
305
|
+
with self._stderr_state_condition:
|
|
306
|
+
self._stderr_state_condition.notify_all()
|
|
307
|
+
for response_queue in pending:
|
|
308
|
+
response_queue.put(fatal_state)
|
|
309
|
+
|
|
310
|
+
def _set_request_state_locked(self, request_id: int | str, state: str) -> None:
|
|
311
|
+
if state == _REQUEST_STATE_COMPLETED:
|
|
312
|
+
self._request_states.pop(request_id, None)
|
|
313
|
+
return
|
|
314
|
+
self._request_states[request_id] = state
|
|
315
|
+
if state not in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
316
|
+
return
|
|
317
|
+
self._retired_request_state_ids.append(request_id)
|
|
318
|
+
while len(self._retired_request_state_ids) > _RETIRED_REQUEST_STATE_RETENTION_LIMIT:
|
|
319
|
+
retired_request_id = self._retired_request_state_ids.popleft()
|
|
320
|
+
if (
|
|
321
|
+
self._request_states.get(retired_request_id)
|
|
322
|
+
in _REQUEST_STATES_DISCARD_LATE_RESPONSES
|
|
323
|
+
):
|
|
324
|
+
self._request_states.pop(retired_request_id, None)
|
|
325
|
+
|
|
326
|
+
def _finalize_fatal_error(self, fatal_state: _FatalTransportState) -> McpStdioTransportError:
|
|
327
|
+
stderr_tail = self._wait_for_stderr_quiescence(
|
|
328
|
+
after_sequence=fatal_state.stderr_append_sequence,
|
|
329
|
+
after_read_sequence=fatal_state.stderr_read_sequence,
|
|
330
|
+
quiet_since=max(
|
|
331
|
+
fatal_state.stderr_last_activity_monotonic,
|
|
332
|
+
fatal_state.fatal_monotonic,
|
|
333
|
+
),
|
|
334
|
+
)
|
|
335
|
+
return fatal_state.exc_type(
|
|
336
|
+
self._format_error_message(
|
|
337
|
+
fatal_state.message,
|
|
338
|
+
stderr_tail=stderr_tail,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _raise_if_fatal(self) -> None:
|
|
343
|
+
if self._fatal_state is None:
|
|
344
|
+
return
|
|
345
|
+
raise self._finalize_fatal_error(self._fatal_state)
|
|
346
|
+
|
|
347
|
+
def _fd_bytes_available(self, fd: int) -> bool:
|
|
348
|
+
if fd < 0:
|
|
349
|
+
return False
|
|
350
|
+
if os.name == "nt":
|
|
351
|
+
try:
|
|
352
|
+
import msvcrt
|
|
353
|
+
|
|
354
|
+
handle = msvcrt.get_osfhandle(fd)
|
|
355
|
+
available = ctypes.c_ulong(0)
|
|
356
|
+
ok = ctypes.windll.kernel32.PeekNamedPipe( # type: ignore[attr-defined]
|
|
357
|
+
ctypes.c_void_p(handle),
|
|
358
|
+
None,
|
|
359
|
+
0,
|
|
360
|
+
None,
|
|
361
|
+
ctypes.byref(available),
|
|
362
|
+
None,
|
|
363
|
+
)
|
|
364
|
+
return bool(ok) and available.value > 0
|
|
365
|
+
except Exception:
|
|
366
|
+
return False
|
|
367
|
+
try:
|
|
368
|
+
readable, _, _ = select.select([fd], [], [], 0)
|
|
369
|
+
except Exception:
|
|
370
|
+
return False
|
|
371
|
+
return bool(readable)
|
|
372
|
+
|
|
373
|
+
def _read_fd_chunk(self, fd: int, *, size: int) -> bytes:
|
|
374
|
+
while True:
|
|
375
|
+
try:
|
|
376
|
+
return os.read(fd, size)
|
|
377
|
+
except InterruptedError:
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
def _wait_for_fd_readable(self, fd: int, *, timeout_s: float) -> bool:
|
|
381
|
+
if timeout_s <= 0:
|
|
382
|
+
return self._fd_bytes_available(fd)
|
|
383
|
+
if os.name != "nt":
|
|
384
|
+
try:
|
|
385
|
+
readable, _, _ = select.select([fd], [], [], timeout_s)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
else:
|
|
389
|
+
return bool(readable)
|
|
390
|
+
deadline = time.monotonic() + timeout_s
|
|
391
|
+
while True:
|
|
392
|
+
if self._fd_bytes_available(fd):
|
|
393
|
+
return True
|
|
394
|
+
process = self._process
|
|
395
|
+
if process is not None and process.poll() is not None:
|
|
396
|
+
return True
|
|
397
|
+
remaining = deadline - time.monotonic()
|
|
398
|
+
if remaining <= 0:
|
|
399
|
+
return False
|
|
400
|
+
time.sleep(min(_FD_READY_POLL_INTERVAL_S, remaining))
|
|
401
|
+
|
|
402
|
+
def _mark_stdout_quiescent(self) -> int:
|
|
403
|
+
# A new generation is published only after the reader has drained every
|
|
404
|
+
# currently available stdout line and is about to block for more input.
|
|
405
|
+
with self._stdout_state_condition:
|
|
406
|
+
self._stdout_reader_idle = True
|
|
407
|
+
self._stdout_quiescence_generation += 1
|
|
408
|
+
generation = self._stdout_quiescence_generation
|
|
409
|
+
self._stdout_state_condition.notify_all()
|
|
410
|
+
return generation
|
|
411
|
+
|
|
412
|
+
def _set_stdout_reader_active(self) -> None:
|
|
413
|
+
with self._stdout_state_condition:
|
|
414
|
+
if not self._stdout_reader_idle:
|
|
415
|
+
return
|
|
416
|
+
self._stdout_reader_idle = False
|
|
417
|
+
self._stdout_state_condition.notify_all()
|
|
418
|
+
|
|
419
|
+
def _mark_stdout_reader_closed(self) -> None:
|
|
420
|
+
with self._stdout_state_condition:
|
|
421
|
+
self._stdout_reader_idle = True
|
|
422
|
+
self._stdout_reader_closed = True
|
|
423
|
+
self._stdout_quiescence_generation += 1
|
|
424
|
+
self._stdout_state_condition.notify_all()
|
|
425
|
+
|
|
426
|
+
def _mark_stdout_bytes_read(self) -> None:
|
|
427
|
+
with self._stdout_state_condition:
|
|
428
|
+
self._stdout_read_sequence += 1
|
|
429
|
+
self._stdout_last_activity_monotonic = time.monotonic()
|
|
430
|
+
self._stdout_state_condition.notify_all()
|
|
431
|
+
|
|
432
|
+
def _wait_for_post_write_quiescence(
|
|
433
|
+
self,
|
|
434
|
+
*,
|
|
435
|
+
after_generation: int,
|
|
436
|
+
after_read_sequence: int,
|
|
437
|
+
timeout_s: float,
|
|
438
|
+
allow_idle_return_without_activity: bool,
|
|
439
|
+
) -> None:
|
|
440
|
+
# Notifications have no response marker. The host therefore waits for the
|
|
441
|
+
# next reader-published quiescent boundary after the write. A short no-output
|
|
442
|
+
# grace window handles notifications with no completion timeout. When the
|
|
443
|
+
# caller provides an explicit completion timeout, "no output yet" remains
|
|
444
|
+
# pending until the full timeout elapses so delayed post-write stdout can
|
|
445
|
+
# still surface before success.
|
|
446
|
+
activity_deadline = time.monotonic() + timeout_s
|
|
447
|
+
no_output_deadline = time.monotonic() + (
|
|
448
|
+
_STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S
|
|
449
|
+
if allow_idle_return_without_activity
|
|
450
|
+
else timeout_s
|
|
451
|
+
)
|
|
452
|
+
while True:
|
|
453
|
+
with self._stdout_state_condition:
|
|
454
|
+
if self._fatal_state is not None:
|
|
455
|
+
return
|
|
456
|
+
if self._stdout_reader_closed:
|
|
457
|
+
return
|
|
458
|
+
if self._stdout_quiescence_generation > after_generation:
|
|
459
|
+
return
|
|
460
|
+
read_sequence = self._stdout_read_sequence
|
|
461
|
+
reader_idle = self._stdout_reader_idle
|
|
462
|
+
observed_post_write_activity = read_sequence > after_read_sequence
|
|
463
|
+
if not observed_post_write_activity and reader_idle:
|
|
464
|
+
remaining = no_output_deadline - time.monotonic()
|
|
465
|
+
if remaining <= 0:
|
|
466
|
+
return
|
|
467
|
+
else:
|
|
468
|
+
remaining = activity_deadline - time.monotonic()
|
|
469
|
+
if remaining <= 0:
|
|
470
|
+
raise self._build_error(
|
|
471
|
+
"timed out waiting for post-notification stdout quiescence.",
|
|
472
|
+
exc_type=McpStdioTransportTimeoutError,
|
|
473
|
+
)
|
|
474
|
+
with self._stdout_state_condition:
|
|
475
|
+
if self._fatal_state is not None:
|
|
476
|
+
return
|
|
477
|
+
if self._stdout_reader_closed:
|
|
478
|
+
return
|
|
479
|
+
if self._stdout_quiescence_generation > after_generation:
|
|
480
|
+
return
|
|
481
|
+
if not observed_post_write_activity and reader_idle:
|
|
482
|
+
remaining = no_output_deadline - time.monotonic()
|
|
483
|
+
else:
|
|
484
|
+
remaining = activity_deadline - time.monotonic()
|
|
485
|
+
if remaining <= 0:
|
|
486
|
+
if not observed_post_write_activity and reader_idle:
|
|
487
|
+
return
|
|
488
|
+
raise self._build_error(
|
|
489
|
+
"timed out waiting for post-notification stdout quiescence.",
|
|
490
|
+
exc_type=McpStdioTransportTimeoutError,
|
|
491
|
+
)
|
|
492
|
+
self._stdout_state_condition.wait(timeout=remaining)
|
|
493
|
+
|
|
494
|
+
def _append_stderr_line(self, line: str) -> None:
|
|
495
|
+
cleaned = line.rstrip("\r")
|
|
496
|
+
if not cleaned:
|
|
497
|
+
return
|
|
498
|
+
with self._stderr_state_condition:
|
|
499
|
+
self._stderr_tail.append(cleaned)
|
|
500
|
+
self._stderr_append_sequence += 1
|
|
501
|
+
self._stderr_last_activity_monotonic = time.monotonic()
|
|
502
|
+
self._stderr_state_condition.notify_all()
|
|
503
|
+
|
|
504
|
+
def _mark_stderr_quiescent(self) -> int:
|
|
505
|
+
with self._stderr_state_condition:
|
|
506
|
+
self._stderr_reader_idle = True
|
|
507
|
+
self._stderr_quiescence_generation += 1
|
|
508
|
+
generation = self._stderr_quiescence_generation
|
|
509
|
+
self._stderr_state_condition.notify_all()
|
|
510
|
+
return generation
|
|
511
|
+
|
|
512
|
+
def _mark_stderr_bytes_read(self) -> None:
|
|
513
|
+
with self._stderr_state_condition:
|
|
514
|
+
self._stderr_read_sequence += 1
|
|
515
|
+
self._stderr_last_activity_monotonic = time.monotonic()
|
|
516
|
+
self._stderr_state_condition.notify_all()
|
|
517
|
+
|
|
518
|
+
def _set_stderr_reader_active(self) -> None:
|
|
519
|
+
with self._stderr_state_condition:
|
|
520
|
+
if not self._stderr_reader_idle:
|
|
521
|
+
return
|
|
522
|
+
self._stderr_reader_idle = False
|
|
523
|
+
self._stderr_state_condition.notify_all()
|
|
524
|
+
|
|
525
|
+
def _mark_stderr_reader_closed(self) -> None:
|
|
526
|
+
with self._stderr_state_condition:
|
|
527
|
+
self._stderr_reader_idle = True
|
|
528
|
+
self._stderr_reader_closed = True
|
|
529
|
+
self._stderr_quiescence_generation += 1
|
|
530
|
+
self._stderr_state_condition.notify_all()
|
|
531
|
+
|
|
532
|
+
def _wait_for_stderr_quiescence(
|
|
533
|
+
self,
|
|
534
|
+
*,
|
|
535
|
+
after_sequence: int,
|
|
536
|
+
after_read_sequence: int,
|
|
537
|
+
quiet_since: float,
|
|
538
|
+
) -> str:
|
|
539
|
+
# Fatal stderr diagnostics stay live until stderr has actually gone quiet
|
|
540
|
+
# after the fatal point; the first later quiescent generation is not enough.
|
|
541
|
+
observed_append_sequence = after_sequence
|
|
542
|
+
observed_read_sequence = after_read_sequence
|
|
543
|
+
while True:
|
|
544
|
+
with self._stderr_state_condition:
|
|
545
|
+
append_sequence = self._stderr_append_sequence
|
|
546
|
+
read_sequence = self._stderr_read_sequence
|
|
547
|
+
last_activity_monotonic = self._stderr_last_activity_monotonic
|
|
548
|
+
reader_closed = self._stderr_reader_closed
|
|
549
|
+
if (
|
|
550
|
+
append_sequence > observed_append_sequence
|
|
551
|
+
or read_sequence > observed_read_sequence
|
|
552
|
+
):
|
|
553
|
+
observed_append_sequence = append_sequence
|
|
554
|
+
observed_read_sequence = read_sequence
|
|
555
|
+
quiet_since = max(quiet_since, last_activity_monotonic)
|
|
556
|
+
if reader_closed:
|
|
557
|
+
joined = "\n".join(self._stderr_tail)
|
|
558
|
+
break
|
|
559
|
+
remaining = (quiet_since + _STDERR_QUIESCENCE_WAIT_S) - time.monotonic()
|
|
560
|
+
if remaining <= 0:
|
|
561
|
+
joined = "\n".join(self._stderr_tail)
|
|
562
|
+
break
|
|
563
|
+
self._stderr_state_condition.wait(timeout=remaining)
|
|
564
|
+
if len(joined) <= _STDERR_CHAR_LIMIT:
|
|
565
|
+
return joined.strip()
|
|
566
|
+
return joined[-_STDERR_CHAR_LIMIT:].strip()
|
|
567
|
+
|
|
568
|
+
def _wait_for_deferred_response_follow_up(
|
|
569
|
+
self,
|
|
570
|
+
*,
|
|
571
|
+
stdout_fd: int,
|
|
572
|
+
deferred_responses: Mapping[int | str, _DeferredResponse],
|
|
573
|
+
) -> bool:
|
|
574
|
+
with self._stdout_state_condition:
|
|
575
|
+
quiet_deadline = (
|
|
576
|
+
self._stdout_last_activity_monotonic + _STDOUT_RESPONSE_QUIESCENCE_WAIT_S
|
|
577
|
+
)
|
|
578
|
+
earliest_follow_up_deadline = min(
|
|
579
|
+
deferred.follow_up_deadline_monotonic for deferred in deferred_responses.values()
|
|
580
|
+
)
|
|
581
|
+
remaining = min(
|
|
582
|
+
quiet_deadline - time.monotonic(),
|
|
583
|
+
earliest_follow_up_deadline - time.monotonic(),
|
|
584
|
+
)
|
|
585
|
+
if remaining <= 0:
|
|
586
|
+
return False
|
|
587
|
+
return self._wait_for_fd_readable(stdout_fd, timeout_s=remaining)
|
|
588
|
+
|
|
589
|
+
def _deferred_response_ids_ready_for_release(
|
|
590
|
+
self,
|
|
591
|
+
deferred_responses: Mapping[int | str, _DeferredResponse],
|
|
592
|
+
) -> tuple[int | str, ...]:
|
|
593
|
+
if not deferred_responses:
|
|
594
|
+
return ()
|
|
595
|
+
with self._stdout_state_condition:
|
|
596
|
+
quiet_deadline = (
|
|
597
|
+
self._stdout_last_activity_monotonic + _STDOUT_RESPONSE_QUIESCENCE_WAIT_S
|
|
598
|
+
)
|
|
599
|
+
now = time.monotonic()
|
|
600
|
+
if now >= quiet_deadline:
|
|
601
|
+
return tuple(deferred_responses.keys())
|
|
602
|
+
return ()
|
|
603
|
+
|
|
604
|
+
def _deferred_response_ids_timed_out(
|
|
605
|
+
self,
|
|
606
|
+
deferred_responses: Mapping[int | str, _DeferredResponse],
|
|
607
|
+
) -> tuple[int | str, ...]:
|
|
608
|
+
now = time.monotonic()
|
|
609
|
+
return tuple(
|
|
610
|
+
request_id
|
|
611
|
+
for request_id, deferred in deferred_responses.items()
|
|
612
|
+
if now >= deferred.follow_up_deadline_monotonic
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def start(self) -> None:
|
|
616
|
+
if self._started:
|
|
617
|
+
self._raise_if_fatal()
|
|
618
|
+
return
|
|
619
|
+
if self._closed:
|
|
620
|
+
raise self._build_error("transport is already closed.")
|
|
621
|
+
try:
|
|
622
|
+
self._process = subprocess.Popen(
|
|
623
|
+
[self.server.command, *self.server.args],
|
|
624
|
+
cwd=self.workspace_root,
|
|
625
|
+
env=self.spawn_env,
|
|
626
|
+
stdin=subprocess.PIPE,
|
|
627
|
+
stdout=subprocess.PIPE,
|
|
628
|
+
stderr=subprocess.PIPE,
|
|
629
|
+
bufsize=0,
|
|
630
|
+
)
|
|
631
|
+
except Exception as exc: # noqa: BLE001
|
|
632
|
+
raise self._build_error(f"failed to launch process: {exc}") from exc
|
|
633
|
+
self._started = True
|
|
634
|
+
_LIVE_STDIO_TRANSPORTS.add(self)
|
|
635
|
+
self._stdout_thread = threading.Thread(
|
|
636
|
+
target=self._stdout_reader,
|
|
637
|
+
name=f"mcp-stdout-{self.server.id}",
|
|
638
|
+
daemon=True,
|
|
639
|
+
)
|
|
640
|
+
self._stderr_thread = threading.Thread(
|
|
641
|
+
target=self._stderr_reader,
|
|
642
|
+
name=f"mcp-stderr-{self.server.id}",
|
|
643
|
+
daemon=True,
|
|
644
|
+
)
|
|
645
|
+
self._stdout_thread.start()
|
|
646
|
+
self._stderr_thread.start()
|
|
647
|
+
|
|
648
|
+
def _stderr_reader(self) -> None:
|
|
649
|
+
process = self._process
|
|
650
|
+
if process is None or process.stderr is None:
|
|
651
|
+
return
|
|
652
|
+
try:
|
|
653
|
+
stderr_fd = process.stderr.fileno()
|
|
654
|
+
except Exception:
|
|
655
|
+
self._mark_stderr_reader_closed()
|
|
656
|
+
return
|
|
657
|
+
buffered_bytes = bytearray()
|
|
658
|
+
self._mark_stderr_quiescent()
|
|
659
|
+
try:
|
|
660
|
+
while True:
|
|
661
|
+
chunk = self._read_fd_chunk(stderr_fd, size=_STDERR_READ_CHUNK_SIZE)
|
|
662
|
+
if not chunk:
|
|
663
|
+
if buffered_bytes:
|
|
664
|
+
self._append_stderr_line(
|
|
665
|
+
bytes(buffered_bytes).decode("utf-8", errors="replace")
|
|
666
|
+
)
|
|
667
|
+
buffered_bytes.clear()
|
|
668
|
+
break
|
|
669
|
+
self._set_stderr_reader_active()
|
|
670
|
+
self._mark_stderr_bytes_read()
|
|
671
|
+
buffered_bytes.extend(chunk)
|
|
672
|
+
self._drain_stderr_lines(buffered_bytes)
|
|
673
|
+
while self._fd_bytes_available(stderr_fd):
|
|
674
|
+
extra_chunk = self._read_fd_chunk(
|
|
675
|
+
stderr_fd,
|
|
676
|
+
size=_STDERR_READ_CHUNK_SIZE,
|
|
677
|
+
)
|
|
678
|
+
if not extra_chunk:
|
|
679
|
+
break
|
|
680
|
+
self._mark_stderr_bytes_read()
|
|
681
|
+
buffered_bytes.extend(extra_chunk)
|
|
682
|
+
self._drain_stderr_lines(buffered_bytes)
|
|
683
|
+
if not buffered_bytes:
|
|
684
|
+
self._mark_stderr_quiescent()
|
|
685
|
+
except Exception: # noqa: BLE001
|
|
686
|
+
pass
|
|
687
|
+
finally:
|
|
688
|
+
self._mark_stderr_reader_closed()
|
|
689
|
+
|
|
690
|
+
def _stdout_reader(self) -> None:
|
|
691
|
+
process = self._process
|
|
692
|
+
if process is None or process.stdout is None:
|
|
693
|
+
return
|
|
694
|
+
try:
|
|
695
|
+
stdout_fd = process.stdout.fileno()
|
|
696
|
+
except Exception:
|
|
697
|
+
self._set_fatal_error(self._build_fatal_state("stdout binary stream is unavailable."))
|
|
698
|
+
return
|
|
699
|
+
buffered_bytes = bytearray()
|
|
700
|
+
deferred_responses: dict[int | str, _DeferredResponse] = {}
|
|
701
|
+
self._mark_stdout_quiescent()
|
|
702
|
+
try:
|
|
703
|
+
while True:
|
|
704
|
+
if not buffered_bytes and deferred_responses and self._fatal_state is None:
|
|
705
|
+
if not self._wait_for_deferred_response_follow_up(
|
|
706
|
+
stdout_fd=stdout_fd,
|
|
707
|
+
deferred_responses=deferred_responses,
|
|
708
|
+
):
|
|
709
|
+
self._mark_stdout_quiescent()
|
|
710
|
+
self._flush_deferred_responses(deferred_responses)
|
|
711
|
+
continue
|
|
712
|
+
chunk = self._read_fd_chunk(stdout_fd, size=_STDOUT_READ_CHUNK_SIZE)
|
|
713
|
+
if not chunk:
|
|
714
|
+
if buffered_bytes:
|
|
715
|
+
trailing_line = _decode_stdout_protocol_line(bytes(buffered_bytes))
|
|
716
|
+
for message in parse_jsonrpc_line(trailing_line):
|
|
717
|
+
self._handle_incoming_message(
|
|
718
|
+
message,
|
|
719
|
+
deferred_responses=deferred_responses,
|
|
720
|
+
)
|
|
721
|
+
buffered_bytes.clear()
|
|
722
|
+
break
|
|
723
|
+
self._set_stdout_reader_active()
|
|
724
|
+
self._mark_stdout_bytes_read()
|
|
725
|
+
buffered_bytes.extend(chunk)
|
|
726
|
+
self._drain_stdout_lines(
|
|
727
|
+
buffered_bytes,
|
|
728
|
+
deferred_responses=deferred_responses,
|
|
729
|
+
)
|
|
730
|
+
while self._fd_bytes_available(stdout_fd):
|
|
731
|
+
extra_chunk = self._read_fd_chunk(
|
|
732
|
+
stdout_fd,
|
|
733
|
+
size=_STDOUT_READ_CHUNK_SIZE,
|
|
734
|
+
)
|
|
735
|
+
if not extra_chunk:
|
|
736
|
+
break
|
|
737
|
+
self._mark_stdout_bytes_read()
|
|
738
|
+
buffered_bytes.extend(extra_chunk)
|
|
739
|
+
self._drain_stdout_lines(
|
|
740
|
+
buffered_bytes,
|
|
741
|
+
deferred_responses=deferred_responses,
|
|
742
|
+
)
|
|
743
|
+
if not buffered_bytes and not deferred_responses:
|
|
744
|
+
self._mark_stdout_quiescent()
|
|
745
|
+
self._flush_deferred_responses(deferred_responses)
|
|
746
|
+
except JsonRpcProtocolError as exc:
|
|
747
|
+
self._set_fatal_error(
|
|
748
|
+
self._build_fatal_state(
|
|
749
|
+
f"received malformed JSON-RPC payload: {exc}",
|
|
750
|
+
exc_type=McpStdioTransportProtocolError,
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
except McpStdioTransportProtocolError as exc:
|
|
754
|
+
self._set_fatal_error(
|
|
755
|
+
self._build_fatal_state(
|
|
756
|
+
str(exc),
|
|
757
|
+
exc_type=McpStdioTransportProtocolError,
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
except Exception as exc: # noqa: BLE001
|
|
761
|
+
if not self._closing:
|
|
762
|
+
self._set_fatal_error(self._build_fatal_state(f"stdout reader failed: {exc}"))
|
|
763
|
+
finally:
|
|
764
|
+
self._mark_stdout_reader_closed()
|
|
765
|
+
if not self._closing:
|
|
766
|
+
return_code = process.poll()
|
|
767
|
+
detail = "stdio process exited unexpectedly"
|
|
768
|
+
if return_code is not None:
|
|
769
|
+
detail = f"stdio process exited with code {return_code}"
|
|
770
|
+
self._set_fatal_error(self._build_fatal_state(detail))
|
|
771
|
+
|
|
772
|
+
def _drain_stderr_lines(self, buffered_bytes: bytearray) -> None:
|
|
773
|
+
while True:
|
|
774
|
+
newline_index = buffered_bytes.find(b"\n")
|
|
775
|
+
if newline_index < 0:
|
|
776
|
+
return
|
|
777
|
+
line_bytes = bytes(buffered_bytes[:newline_index])
|
|
778
|
+
del buffered_bytes[: newline_index + 1]
|
|
779
|
+
self._append_stderr_line(line_bytes.decode("utf-8", errors="replace"))
|
|
780
|
+
|
|
781
|
+
def _drain_stdout_lines(
|
|
782
|
+
self,
|
|
783
|
+
buffered_bytes: bytearray,
|
|
784
|
+
*,
|
|
785
|
+
deferred_responses: dict[int | str, _DeferredResponse],
|
|
786
|
+
) -> None:
|
|
787
|
+
while True:
|
|
788
|
+
newline_index = buffered_bytes.find(b"\n")
|
|
789
|
+
if newline_index < 0:
|
|
790
|
+
return
|
|
791
|
+
line_bytes = bytes(buffered_bytes[:newline_index])
|
|
792
|
+
del buffered_bytes[: newline_index + 1]
|
|
793
|
+
line = _decode_stdout_protocol_line(line_bytes)
|
|
794
|
+
for message in parse_jsonrpc_line(line):
|
|
795
|
+
self._handle_incoming_message(
|
|
796
|
+
message,
|
|
797
|
+
deferred_responses=deferred_responses,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
def _flush_deferred_responses(
|
|
801
|
+
self, deferred_responses: dict[int | str, _DeferredResponse]
|
|
802
|
+
) -> None:
|
|
803
|
+
# Responses stay hidden until the reader reaches the current stdout quiet
|
|
804
|
+
# boundary. If later stdout activity moves that boundary forward and the
|
|
805
|
+
# bounded follow-up budget expires first, the active call fails instead.
|
|
806
|
+
if not deferred_responses or self._fatal_state is not None:
|
|
807
|
+
deferred_responses.clear()
|
|
808
|
+
return
|
|
809
|
+
ready_ids = self._deferred_response_ids_ready_for_release(deferred_responses)
|
|
810
|
+
timed_out_ids = self._deferred_response_ids_timed_out(deferred_responses)
|
|
811
|
+
if timed_out_ids:
|
|
812
|
+
timed_out_id_set = set(timed_out_ids)
|
|
813
|
+
ready_ids = tuple(
|
|
814
|
+
request_id for request_id in ready_ids if request_id not in timed_out_id_set
|
|
815
|
+
)
|
|
816
|
+
if not ready_ids and not timed_out_ids:
|
|
817
|
+
return
|
|
818
|
+
queued: list[int | str] = []
|
|
819
|
+
unknown_request_id: int | str | None = None
|
|
820
|
+
duplicate_request_id: int | str | None = None
|
|
821
|
+
expiring_ids = timed_out_ids
|
|
822
|
+
releasable_ids = ready_ids
|
|
823
|
+
with self._pending_lock:
|
|
824
|
+
for request_id in expiring_ids:
|
|
825
|
+
pending_request = self._pending.get(request_id)
|
|
826
|
+
request_state = self._request_states.get(request_id)
|
|
827
|
+
if pending_request is None:
|
|
828
|
+
if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
829
|
+
queued.append(request_id)
|
|
830
|
+
continue
|
|
831
|
+
if request_state == _REQUEST_STATE_COMPLETED:
|
|
832
|
+
duplicate_request_id = request_id
|
|
833
|
+
break
|
|
834
|
+
unknown_request_id = request_id
|
|
835
|
+
break
|
|
836
|
+
if request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
|
|
837
|
+
pending_request.response_queue.put(_DeferredResponseFollowUpTimeout())
|
|
838
|
+
self._pending.pop(request_id, None)
|
|
839
|
+
self._set_request_state_locked(request_id, _REQUEST_STATE_FOLLOW_UP_TIMED_OUT)
|
|
840
|
+
queued.append(request_id)
|
|
841
|
+
continue
|
|
842
|
+
if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
843
|
+
self._pending.pop(request_id, None)
|
|
844
|
+
queued.append(request_id)
|
|
845
|
+
continue
|
|
846
|
+
unknown_request_id = request_id
|
|
847
|
+
break
|
|
848
|
+
if unknown_request_id is None and duplicate_request_id is None:
|
|
849
|
+
for request_id in releasable_ids:
|
|
850
|
+
deferred = deferred_responses.get(request_id)
|
|
851
|
+
if deferred is None:
|
|
852
|
+
continue
|
|
853
|
+
pending_request = self._pending.get(request_id)
|
|
854
|
+
request_state = self._request_states.get(request_id)
|
|
855
|
+
if pending_request is None:
|
|
856
|
+
if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
857
|
+
queued.append(request_id)
|
|
858
|
+
continue
|
|
859
|
+
if request_state == _REQUEST_STATE_COMPLETED:
|
|
860
|
+
duplicate_request_id = request_id
|
|
861
|
+
break
|
|
862
|
+
unknown_request_id = request_id
|
|
863
|
+
break
|
|
864
|
+
if request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
|
|
865
|
+
pending_request.response_queue.put(
|
|
866
|
+
_QueuedResponse(response=deferred.response)
|
|
867
|
+
)
|
|
868
|
+
self._pending.pop(request_id, None)
|
|
869
|
+
self._set_request_state_locked(request_id, _REQUEST_STATE_COMPLETED)
|
|
870
|
+
queued.append(request_id)
|
|
871
|
+
continue
|
|
872
|
+
if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
873
|
+
self._pending.pop(request_id, None)
|
|
874
|
+
queued.append(request_id)
|
|
875
|
+
continue
|
|
876
|
+
unknown_request_id = request_id
|
|
877
|
+
break
|
|
878
|
+
if duplicate_request_id is not None:
|
|
879
|
+
self._set_fatal_error(
|
|
880
|
+
self._build_fatal_state(
|
|
881
|
+
f"received duplicate response for request id {duplicate_request_id!r}",
|
|
882
|
+
exc_type=McpStdioTransportProtocolError,
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
return
|
|
886
|
+
if unknown_request_id is not None:
|
|
887
|
+
self._set_fatal_error(
|
|
888
|
+
self._build_fatal_state(
|
|
889
|
+
f"received response for unknown request id {unknown_request_id!r}",
|
|
890
|
+
exc_type=McpStdioTransportProtocolError,
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
return
|
|
894
|
+
for request_id in queued:
|
|
895
|
+
deferred_responses.pop(request_id, None)
|
|
896
|
+
|
|
897
|
+
def _handle_response_message(
|
|
898
|
+
self,
|
|
899
|
+
message: JsonRpcResponse,
|
|
900
|
+
*,
|
|
901
|
+
deferred_responses: dict[int | str, _DeferredResponse],
|
|
902
|
+
) -> None:
|
|
903
|
+
if message.id in deferred_responses:
|
|
904
|
+
self._set_fatal_error(
|
|
905
|
+
self._build_fatal_state(
|
|
906
|
+
f"received duplicate response for request id {message.id!r}",
|
|
907
|
+
exc_type=McpStdioTransportProtocolError,
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
return
|
|
911
|
+
fatal_message: str | None = None
|
|
912
|
+
with self._pending_lock:
|
|
913
|
+
pending_request = self._pending.get(message.id)
|
|
914
|
+
request_state = self._request_states.get(message.id)
|
|
915
|
+
if pending_request is None:
|
|
916
|
+
if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
917
|
+
return
|
|
918
|
+
if request_state == _REQUEST_STATE_COMPLETED:
|
|
919
|
+
fatal_message = f"received duplicate response for request id {message.id!r}"
|
|
920
|
+
else:
|
|
921
|
+
fatal_message = f"received response for unknown request id {message.id!r}"
|
|
922
|
+
elif request_state == _REQUEST_STATE_AWAITING_RESPONSE:
|
|
923
|
+
follow_up_deadline = time.monotonic() + pending_request.follow_up_budget_s
|
|
924
|
+
pending_request.response_queue.put(
|
|
925
|
+
_ObservedResponse(follow_up_deadline_monotonic=follow_up_deadline)
|
|
926
|
+
)
|
|
927
|
+
self._set_request_state_locked(message.id, _REQUEST_STATE_AWAITING_FOLLOW_UP)
|
|
928
|
+
deferred_responses[message.id] = _DeferredResponse(
|
|
929
|
+
response=message,
|
|
930
|
+
follow_up_deadline_monotonic=follow_up_deadline,
|
|
931
|
+
)
|
|
932
|
+
return
|
|
933
|
+
elif request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
|
|
934
|
+
fatal_message = f"received duplicate response for request id {message.id!r}"
|
|
935
|
+
elif request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
|
|
936
|
+
self._pending.pop(message.id, None)
|
|
937
|
+
return
|
|
938
|
+
else:
|
|
939
|
+
fatal_message = f"received response for unknown request id {message.id!r}"
|
|
940
|
+
if fatal_message is not None:
|
|
941
|
+
self._set_fatal_error(
|
|
942
|
+
self._build_fatal_state(
|
|
943
|
+
fatal_message,
|
|
944
|
+
exc_type=McpStdioTransportProtocolError,
|
|
945
|
+
)
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
def _retire_request_on_timeout(
|
|
949
|
+
self,
|
|
950
|
+
*,
|
|
951
|
+
request_id: int | str,
|
|
952
|
+
observed_response: bool,
|
|
953
|
+
) -> None:
|
|
954
|
+
with self._pending_lock:
|
|
955
|
+
pending_request = self._pending.pop(request_id, None)
|
|
956
|
+
request_state = self._request_states.get(request_id)
|
|
957
|
+
if pending_request is None and request_state == _REQUEST_STATE_COMPLETED:
|
|
958
|
+
return
|
|
959
|
+
if observed_response or request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
|
|
960
|
+
self._set_request_state_locked(request_id, _REQUEST_STATE_FOLLOW_UP_TIMED_OUT)
|
|
961
|
+
return
|
|
962
|
+
self._set_request_state_locked(request_id, _REQUEST_STATE_RESPONSE_TIMED_OUT)
|
|
963
|
+
|
|
964
|
+
def _send_serialized(self, payload: dict[str, Any]) -> None:
|
|
965
|
+
process = self._process
|
|
966
|
+
if process is None or process.stdin is None:
|
|
967
|
+
raise self._build_error("stdin is unavailable.")
|
|
968
|
+
if process.poll() is not None:
|
|
969
|
+
raise self._build_error(f"stdio process already exited with code {process.poll()}")
|
|
970
|
+
with self._write_lock:
|
|
971
|
+
try:
|
|
972
|
+
process.stdin.write((encode_jsonrpc_message(payload) + "\n").encode("utf-8"))
|
|
973
|
+
process.stdin.flush()
|
|
974
|
+
except Exception as exc: # noqa: BLE001
|
|
975
|
+
raise self._build_error(f"failed to write request: {exc}") from exc
|
|
976
|
+
|
|
977
|
+
def _fatal_unsupported_server_request(self, method: str) -> None:
|
|
978
|
+
self._set_fatal_error(
|
|
979
|
+
self._build_fatal_state(
|
|
980
|
+
f"received unsupported server-initiated request '{method}'",
|
|
981
|
+
exc_type=McpStdioTransportProtocolError,
|
|
982
|
+
)
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
def _handle_server_request_message(self, message: JsonRpcRequest) -> None:
|
|
986
|
+
if self._server_request_handler is None or self._server_request_context is None:
|
|
987
|
+
try:
|
|
988
|
+
self._send_serialized(
|
|
989
|
+
build_jsonrpc_error_response(
|
|
990
|
+
request_id=message.id,
|
|
991
|
+
code=_UNSUPPORTED_CLIENT_REQUEST_CODE,
|
|
992
|
+
message=(
|
|
993
|
+
"Server-initiated requests are not supported by this MCP host runtime."
|
|
994
|
+
),
|
|
995
|
+
)
|
|
996
|
+
)
|
|
997
|
+
except Exception:
|
|
998
|
+
pass
|
|
999
|
+
self._fatal_unsupported_server_request(message.method)
|
|
1000
|
+
return
|
|
1001
|
+
try:
|
|
1002
|
+
result = self._server_request_handler.handle_request(
|
|
1003
|
+
context=self._server_request_context,
|
|
1004
|
+
method=message.method,
|
|
1005
|
+
request_id=message.id,
|
|
1006
|
+
params=message.params,
|
|
1007
|
+
)
|
|
1008
|
+
except McpUnsupportedServerRequestError as exc:
|
|
1009
|
+
try:
|
|
1010
|
+
self._send_serialized(
|
|
1011
|
+
build_jsonrpc_error_response(
|
|
1012
|
+
request_id=message.id,
|
|
1013
|
+
code=exc.code,
|
|
1014
|
+
message=exc.message,
|
|
1015
|
+
)
|
|
1016
|
+
)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
pass
|
|
1019
|
+
self._fatal_unsupported_server_request(message.method)
|
|
1020
|
+
return
|
|
1021
|
+
except McpServerRequestHandlerError as exc:
|
|
1022
|
+
try:
|
|
1023
|
+
self._send_serialized(
|
|
1024
|
+
build_jsonrpc_error_response(
|
|
1025
|
+
request_id=message.id,
|
|
1026
|
+
code=exc.code,
|
|
1027
|
+
message=exc.message,
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
except Exception:
|
|
1031
|
+
pass
|
|
1032
|
+
self._set_fatal_error(
|
|
1033
|
+
self._build_fatal_state(
|
|
1034
|
+
f"failed to handle server-initiated request '{message.method}'",
|
|
1035
|
+
exc_type=McpStdioTransportProtocolError,
|
|
1036
|
+
)
|
|
1037
|
+
)
|
|
1038
|
+
return
|
|
1039
|
+
except Exception:
|
|
1040
|
+
try:
|
|
1041
|
+
self._send_serialized(
|
|
1042
|
+
build_jsonrpc_error_response(
|
|
1043
|
+
request_id=message.id,
|
|
1044
|
+
code=_INTERNAL_CLIENT_REQUEST_CODE,
|
|
1045
|
+
message="Internal MCP host error while handling server-initiated request.",
|
|
1046
|
+
)
|
|
1047
|
+
)
|
|
1048
|
+
except Exception:
|
|
1049
|
+
pass
|
|
1050
|
+
self._set_fatal_error(
|
|
1051
|
+
self._build_fatal_state(
|
|
1052
|
+
f"failed to handle server-initiated request '{message.method}'",
|
|
1053
|
+
exc_type=McpStdioTransportProtocolError,
|
|
1054
|
+
)
|
|
1055
|
+
)
|
|
1056
|
+
return
|
|
1057
|
+
try:
|
|
1058
|
+
self._send_serialized(
|
|
1059
|
+
build_jsonrpc_result_response(
|
|
1060
|
+
request_id=message.id,
|
|
1061
|
+
result=result,
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
except Exception as exc:
|
|
1065
|
+
self._set_fatal_error(
|
|
1066
|
+
self._build_fatal_state(
|
|
1067
|
+
f"failed to respond to server-initiated request '{message.method}': {exc}",
|
|
1068
|
+
exc_type=McpStdioTransportProtocolError,
|
|
1069
|
+
)
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
def _handle_incoming_message(
|
|
1073
|
+
self,
|
|
1074
|
+
message: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse,
|
|
1075
|
+
*,
|
|
1076
|
+
deferred_responses: dict[int | str, _DeferredResponse],
|
|
1077
|
+
) -> None:
|
|
1078
|
+
if isinstance(message, JsonRpcNotification):
|
|
1079
|
+
self._notifications.put(message)
|
|
1080
|
+
return
|
|
1081
|
+
if isinstance(message, JsonRpcResponse):
|
|
1082
|
+
self._handle_response_message(
|
|
1083
|
+
message,
|
|
1084
|
+
deferred_responses=deferred_responses,
|
|
1085
|
+
)
|
|
1086
|
+
return
|
|
1087
|
+
self._handle_server_request_message(message)
|
|
1088
|
+
|
|
1089
|
+
def request(
|
|
1090
|
+
self,
|
|
1091
|
+
*,
|
|
1092
|
+
method: str,
|
|
1093
|
+
params: dict[str, Any] | None,
|
|
1094
|
+
timeout_s: float,
|
|
1095
|
+
) -> JsonRpcResponse:
|
|
1096
|
+
if timeout_s <= 0:
|
|
1097
|
+
raise self._build_error("timeout_s must be > 0.")
|
|
1098
|
+
self.start()
|
|
1099
|
+
self._raise_if_fatal()
|
|
1100
|
+
request_id = self._id_generator.next()
|
|
1101
|
+
response_queue: queue.Queue[Any] = queue.Queue()
|
|
1102
|
+
response_deadline = time.monotonic() + timeout_s
|
|
1103
|
+
with self._pending_lock:
|
|
1104
|
+
self._pending[request_id] = _PendingRequest(
|
|
1105
|
+
response_queue=response_queue,
|
|
1106
|
+
follow_up_budget_s=_STDOUT_RESPONSE_FOLLOW_UP_BUDGET_S,
|
|
1107
|
+
)
|
|
1108
|
+
self._set_request_state_locked(request_id, _REQUEST_STATE_AWAITING_RESPONSE)
|
|
1109
|
+
try:
|
|
1110
|
+
self._send_serialized(
|
|
1111
|
+
build_jsonrpc_request(
|
|
1112
|
+
request_id=request_id,
|
|
1113
|
+
method=method,
|
|
1114
|
+
params=params,
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
except Exception:
|
|
1118
|
+
with self._pending_lock:
|
|
1119
|
+
self._pending.pop(request_id, None)
|
|
1120
|
+
self._set_request_state_locked(request_id, _REQUEST_STATE_CANCELLED)
|
|
1121
|
+
raise
|
|
1122
|
+
current_deadline = response_deadline
|
|
1123
|
+
observed_response = False
|
|
1124
|
+
while True:
|
|
1125
|
+
remaining = current_deadline - time.monotonic()
|
|
1126
|
+
if remaining <= 0:
|
|
1127
|
+
try:
|
|
1128
|
+
response = response_queue.get_nowait()
|
|
1129
|
+
except queue.Empty as exc:
|
|
1130
|
+
self._retire_request_on_timeout(
|
|
1131
|
+
request_id=request_id,
|
|
1132
|
+
observed_response=observed_response,
|
|
1133
|
+
)
|
|
1134
|
+
message = f"timed out waiting for response to '{method}' after {timeout_s:.3f}s"
|
|
1135
|
+
if observed_response:
|
|
1136
|
+
message = (
|
|
1137
|
+
f"timed out waiting for post-response stdout follow-up for "
|
|
1138
|
+
f"'{method}' after {timeout_s:.3f}s"
|
|
1139
|
+
)
|
|
1140
|
+
raise self._build_error(
|
|
1141
|
+
message,
|
|
1142
|
+
exc_type=McpStdioTransportTimeoutError,
|
|
1143
|
+
) from exc
|
|
1144
|
+
else:
|
|
1145
|
+
try:
|
|
1146
|
+
response = response_queue.get(timeout=remaining)
|
|
1147
|
+
except queue.Empty:
|
|
1148
|
+
continue
|
|
1149
|
+
if isinstance(response, _ObservedResponse):
|
|
1150
|
+
observed_response = True
|
|
1151
|
+
current_deadline = response.follow_up_deadline_monotonic
|
|
1152
|
+
continue
|
|
1153
|
+
if isinstance(response, _DeferredResponseFollowUpTimeout):
|
|
1154
|
+
observed_response = True
|
|
1155
|
+
raise self._build_error(
|
|
1156
|
+
f"timed out waiting for post-response stdout follow-up for "
|
|
1157
|
+
f"'{method}' after {timeout_s:.3f}s",
|
|
1158
|
+
exc_type=McpStdioTransportTimeoutError,
|
|
1159
|
+
)
|
|
1160
|
+
if isinstance(response, _FatalTransportState):
|
|
1161
|
+
raise self._finalize_fatal_error(response)
|
|
1162
|
+
if not isinstance(response, _QueuedResponse):
|
|
1163
|
+
raise self._build_error(
|
|
1164
|
+
f"received invalid response object for '{method}'",
|
|
1165
|
+
exc_type=McpStdioTransportProtocolError,
|
|
1166
|
+
)
|
|
1167
|
+
self._raise_if_fatal()
|
|
1168
|
+
return response.response
|
|
1169
|
+
|
|
1170
|
+
def send_notification(
|
|
1171
|
+
self,
|
|
1172
|
+
*,
|
|
1173
|
+
method: str,
|
|
1174
|
+
params: dict[str, Any] | None,
|
|
1175
|
+
completion_timeout_s: float | None = None,
|
|
1176
|
+
) -> None:
|
|
1177
|
+
self.start()
|
|
1178
|
+
self._raise_if_fatal()
|
|
1179
|
+
with self._stdout_state_condition:
|
|
1180
|
+
post_write_generation = self._stdout_quiescence_generation
|
|
1181
|
+
post_write_read_sequence = self._stdout_read_sequence
|
|
1182
|
+
self._send_serialized(
|
|
1183
|
+
build_jsonrpc_notification(
|
|
1184
|
+
method=method,
|
|
1185
|
+
params=params,
|
|
1186
|
+
)
|
|
1187
|
+
)
|
|
1188
|
+
self._wait_for_post_write_quiescence(
|
|
1189
|
+
after_generation=post_write_generation,
|
|
1190
|
+
after_read_sequence=post_write_read_sequence,
|
|
1191
|
+
timeout_s=max(
|
|
1192
|
+
completion_timeout_s or _STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S,
|
|
1193
|
+
_STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S,
|
|
1194
|
+
),
|
|
1195
|
+
allow_idle_return_without_activity=completion_timeout_s is None,
|
|
1196
|
+
)
|
|
1197
|
+
self._raise_if_fatal()
|
|
1198
|
+
|
|
1199
|
+
def drain_notifications(self) -> tuple[JsonRpcNotification, ...]:
|
|
1200
|
+
self._raise_if_fatal()
|
|
1201
|
+
notifications: list[JsonRpcNotification] = []
|
|
1202
|
+
while True:
|
|
1203
|
+
try:
|
|
1204
|
+
notifications.append(self._notifications.get_nowait())
|
|
1205
|
+
except queue.Empty:
|
|
1206
|
+
break
|
|
1207
|
+
self._raise_if_fatal()
|
|
1208
|
+
return tuple(notifications)
|
|
1209
|
+
|
|
1210
|
+
def close(self) -> None:
|
|
1211
|
+
if self._closed:
|
|
1212
|
+
return
|
|
1213
|
+
self._closed = True
|
|
1214
|
+
self._closing = True
|
|
1215
|
+
with self._pending_lock:
|
|
1216
|
+
self._pending.clear()
|
|
1217
|
+
self._request_states.clear()
|
|
1218
|
+
self._retired_request_state_ids.clear()
|
|
1219
|
+
process = self._process
|
|
1220
|
+
if process is None:
|
|
1221
|
+
return
|
|
1222
|
+
try:
|
|
1223
|
+
if process.stdin is not None:
|
|
1224
|
+
process.stdin.close()
|
|
1225
|
+
except Exception:
|
|
1226
|
+
pass
|
|
1227
|
+
try:
|
|
1228
|
+
process.wait(timeout=_SHUTDOWN_WAIT_S)
|
|
1229
|
+
except subprocess.TimeoutExpired:
|
|
1230
|
+
try:
|
|
1231
|
+
process.terminate()
|
|
1232
|
+
process.wait(timeout=_TERMINATE_WAIT_S)
|
|
1233
|
+
except subprocess.TimeoutExpired:
|
|
1234
|
+
process.kill()
|
|
1235
|
+
try:
|
|
1236
|
+
process.wait(timeout=_TERMINATE_WAIT_S)
|
|
1237
|
+
except subprocess.TimeoutExpired:
|
|
1238
|
+
pass
|
|
1239
|
+
finally:
|
|
1240
|
+
for stream in (process.stdout, process.stderr):
|
|
1241
|
+
try:
|
|
1242
|
+
if stream is not None:
|
|
1243
|
+
stream.close()
|
|
1244
|
+
except Exception:
|
|
1245
|
+
pass
|
|
1246
|
+
for thread in (self._stdout_thread, self._stderr_thread):
|
|
1247
|
+
if thread is not None:
|
|
1248
|
+
thread.join(timeout=_READER_THREAD_JOIN_WAIT_S)
|
|
1249
|
+
if not self._has_live_process_or_threads():
|
|
1250
|
+
_LIVE_STDIO_TRANSPORTS.discard(self)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def live_stdio_transport_diagnostics() -> list[dict[str, Any]]:
|
|
1254
|
+
diagnostics: list[dict[str, Any]] = []
|
|
1255
|
+
for transport in list(_LIVE_STDIO_TRANSPORTS):
|
|
1256
|
+
snapshot = transport.diagnostic_snapshot()
|
|
1257
|
+
if (
|
|
1258
|
+
not snapshot["closed"]
|
|
1259
|
+
or snapshot["returncode"] is None
|
|
1260
|
+
or snapshot["stdout_thread_alive"]
|
|
1261
|
+
or snapshot["stderr_thread_alive"]
|
|
1262
|
+
):
|
|
1263
|
+
diagnostics.append(snapshot)
|
|
1264
|
+
else:
|
|
1265
|
+
_LIVE_STDIO_TRANSPORTS.discard(transport)
|
|
1266
|
+
diagnostics.sort(key=lambda item: (str(item["server_id"]), int(item["transport_id"])))
|
|
1267
|
+
return diagnostics
|