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,1338 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .. import __version__
|
|
16
|
+
from .errors import (
|
|
17
|
+
McpAuthError,
|
|
18
|
+
McpProtocolError,
|
|
19
|
+
McpRemoteError,
|
|
20
|
+
McpTimeoutError,
|
|
21
|
+
McpTransportError,
|
|
22
|
+
)
|
|
23
|
+
from .jsonrpc import (
|
|
24
|
+
JsonRpcIdGenerator,
|
|
25
|
+
JsonRpcNotification,
|
|
26
|
+
JsonRpcProtocolError,
|
|
27
|
+
JsonRpcRequest,
|
|
28
|
+
JsonRpcResponse,
|
|
29
|
+
build_jsonrpc_notification,
|
|
30
|
+
build_jsonrpc_request,
|
|
31
|
+
build_jsonrpc_result_response,
|
|
32
|
+
encode_jsonrpc_message,
|
|
33
|
+
parse_jsonrpc_line,
|
|
34
|
+
)
|
|
35
|
+
from .models import ResolvedMcpServer
|
|
36
|
+
from .oauth import (
|
|
37
|
+
McpAuthorizationServerMetadata,
|
|
38
|
+
McpOAuthAuthRequiredError,
|
|
39
|
+
McpOAuthDiscoveryError,
|
|
40
|
+
McpOAuthError,
|
|
41
|
+
McpOAuthInsufficientScopeError,
|
|
42
|
+
McpOAuthReLoginRequired,
|
|
43
|
+
McpOAuthTokenExchangeError,
|
|
44
|
+
discover_authorization_server_metadata,
|
|
45
|
+
is_token_expired,
|
|
46
|
+
parse_www_authenticate_bearer_challenge,
|
|
47
|
+
refresh_access_token,
|
|
48
|
+
resolve_requested_scopes,
|
|
49
|
+
)
|
|
50
|
+
from .oauth_store import (
|
|
51
|
+
McpOAuthTokenRecord,
|
|
52
|
+
McpOAuthTokenStoreError,
|
|
53
|
+
delete_oauth_token_record,
|
|
54
|
+
load_oauth_token_record,
|
|
55
|
+
save_oauth_token_record,
|
|
56
|
+
)
|
|
57
|
+
from .server_requests import (
|
|
58
|
+
McpServerRequestContext,
|
|
59
|
+
McpServerRequestHandler,
|
|
60
|
+
McpServerRequestHandlerError,
|
|
61
|
+
McpUnsupportedServerRequestError,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_HTTP_ACCEPT_HEADER = "application/json, text/event-stream"
|
|
65
|
+
_USER_AGENT = f"alysis-code/{__version__}"
|
|
66
|
+
_SAFE_ERROR_BODY_CHAR_LIMIT = 1000
|
|
67
|
+
_OPEN_ENDED_SSE_FOLLOW_UP_QUIET_WINDOW_S = 0.1
|
|
68
|
+
_OPEN_ENDED_SSE_READER_JOIN_TIMEOUT_S = 1.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class McpHttpTransportError(McpTransportError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class McpHttpTransportTimeoutError(McpHttpTransportError, McpTimeoutError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class McpHttpTransportProtocolError(McpHttpTransportError, McpProtocolError):
|
|
80
|
+
error_code = "mcp_http_transport_protocol_error"
|
|
81
|
+
retryable = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class McpHttpTransportRemoteError(McpHttpTransportError, McpRemoteError):
|
|
85
|
+
error_code = "mcp_http_transport_remote_error"
|
|
86
|
+
retryable = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class McpHttpTransportAuthRequiredError(McpHttpTransportError, McpAuthError):
|
|
90
|
+
error_code = "mcp_http_transport_auth_required"
|
|
91
|
+
retryable = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class McpHttpTransportSessionExpiredError(McpHttpTransportError):
|
|
95
|
+
error_code = "mcp_http_transport_session_expired"
|
|
96
|
+
retryable = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class _SseEvent:
|
|
101
|
+
event: str | None
|
|
102
|
+
event_id: str | None
|
|
103
|
+
retry_ms: int | None
|
|
104
|
+
data: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class _SseReaderItem:
|
|
109
|
+
kind: str
|
|
110
|
+
activity_sequence: int
|
|
111
|
+
activity_monotonic: float
|
|
112
|
+
event: _SseEvent | None = None
|
|
113
|
+
error: BaseException | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class _SseReaderSnapshot:
|
|
118
|
+
activity_sequence: int
|
|
119
|
+
activity_monotonic: float
|
|
120
|
+
event_in_progress: bool
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _SseReaderState:
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._lock = threading.Lock()
|
|
126
|
+
self._latest_activity_sequence = 0
|
|
127
|
+
self._latest_activity_monotonic = 0.0
|
|
128
|
+
self._event_in_progress = False
|
|
129
|
+
|
|
130
|
+
def record_activity(self, *, event_in_progress: bool) -> tuple[int, float]:
|
|
131
|
+
with self._lock:
|
|
132
|
+
self._latest_activity_sequence += 1
|
|
133
|
+
self._latest_activity_monotonic = time.monotonic()
|
|
134
|
+
self._event_in_progress = event_in_progress
|
|
135
|
+
return self._latest_activity_sequence, self._latest_activity_monotonic
|
|
136
|
+
|
|
137
|
+
def set_event_in_progress(self, *, event_in_progress: bool) -> None:
|
|
138
|
+
with self._lock:
|
|
139
|
+
self._event_in_progress = event_in_progress
|
|
140
|
+
|
|
141
|
+
def snapshot(self) -> _SseReaderSnapshot:
|
|
142
|
+
with self._lock:
|
|
143
|
+
return _SseReaderSnapshot(
|
|
144
|
+
activity_sequence=self._latest_activity_sequence,
|
|
145
|
+
activity_monotonic=self._latest_activity_monotonic,
|
|
146
|
+
event_in_progress=self._event_in_progress,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _SseEventParser:
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
self._event_name: str | None = None
|
|
153
|
+
self._event_id: str | None = None
|
|
154
|
+
self._retry_ms: int | None = None
|
|
155
|
+
self._data_lines: list[str] = []
|
|
156
|
+
self._saw_event_field = False
|
|
157
|
+
|
|
158
|
+
def event_in_progress(self, *, has_partial_line: bool) -> bool:
|
|
159
|
+
return (
|
|
160
|
+
has_partial_line
|
|
161
|
+
or self._saw_event_field
|
|
162
|
+
or self._event_name is not None
|
|
163
|
+
or self._event_id is not None
|
|
164
|
+
or self._retry_ms is not None
|
|
165
|
+
or bool(self._data_lines)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _reset(self) -> None:
|
|
169
|
+
self._event_name = None
|
|
170
|
+
self._event_id = None
|
|
171
|
+
self._retry_ms = None
|
|
172
|
+
self._data_lines = []
|
|
173
|
+
self._saw_event_field = False
|
|
174
|
+
|
|
175
|
+
def _flush_event(self) -> _SseEvent | None:
|
|
176
|
+
if (
|
|
177
|
+
not self._saw_event_field
|
|
178
|
+
and self._event_name is None
|
|
179
|
+
and self._event_id is None
|
|
180
|
+
and self._retry_ms is None
|
|
181
|
+
and not self._data_lines
|
|
182
|
+
):
|
|
183
|
+
return None
|
|
184
|
+
if (
|
|
185
|
+
not self._data_lines
|
|
186
|
+
and self._event_name is None
|
|
187
|
+
and self._event_id is None
|
|
188
|
+
and self._retry_ms is None
|
|
189
|
+
):
|
|
190
|
+
self._reset()
|
|
191
|
+
return None
|
|
192
|
+
event = _SseEvent(
|
|
193
|
+
event=self._event_name,
|
|
194
|
+
event_id=self._event_id,
|
|
195
|
+
retry_ms=self._retry_ms,
|
|
196
|
+
data="\n".join(self._data_lines),
|
|
197
|
+
)
|
|
198
|
+
self._reset()
|
|
199
|
+
return event
|
|
200
|
+
|
|
201
|
+
def feed_line(self, line: str) -> _SseEvent | None:
|
|
202
|
+
if line == "":
|
|
203
|
+
return self._flush_event()
|
|
204
|
+
if line.startswith(":"):
|
|
205
|
+
return None
|
|
206
|
+
self._saw_event_field = True
|
|
207
|
+
field_name, sep, field_value = line.partition(":")
|
|
208
|
+
if not sep:
|
|
209
|
+
field_value = ""
|
|
210
|
+
elif field_value.startswith(" "):
|
|
211
|
+
field_value = field_value[1:]
|
|
212
|
+
if field_name == "data":
|
|
213
|
+
self._data_lines.append(field_value)
|
|
214
|
+
return None
|
|
215
|
+
if field_name == "event":
|
|
216
|
+
self._event_name = field_value
|
|
217
|
+
return None
|
|
218
|
+
if field_name == "id":
|
|
219
|
+
self._event_id = field_value
|
|
220
|
+
return None
|
|
221
|
+
if field_name == "retry" and field_value.isdigit():
|
|
222
|
+
self._retry_ms = int(field_value)
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def finish_eof(self) -> _SseEvent | None:
|
|
226
|
+
return self._flush_event()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _extract_sse_line(text_buffer: str, *, eof: bool = False) -> tuple[str | None, str]:
|
|
230
|
+
for index, character in enumerate(text_buffer):
|
|
231
|
+
if character == "\n":
|
|
232
|
+
line = text_buffer[:index]
|
|
233
|
+
if line.endswith("\r"):
|
|
234
|
+
line = line[:-1]
|
|
235
|
+
return line, text_buffer[index + 1 :]
|
|
236
|
+
if character == "\r":
|
|
237
|
+
if index + 1 < len(text_buffer) and text_buffer[index + 1] == "\n":
|
|
238
|
+
return text_buffer[:index], text_buffer[index + 2 :]
|
|
239
|
+
if index + 1 == len(text_buffer) and not eof:
|
|
240
|
+
return None, text_buffer
|
|
241
|
+
return text_buffer[:index], text_buffer[index + 1 :]
|
|
242
|
+
return None, text_buffer
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _redacted_url(value: str) -> str:
|
|
246
|
+
split = urlsplit(value)
|
|
247
|
+
hostname = split.hostname or ""
|
|
248
|
+
if split.port is None:
|
|
249
|
+
netloc = hostname
|
|
250
|
+
else:
|
|
251
|
+
netloc = f"{hostname}:{split.port}"
|
|
252
|
+
redacted = SplitResult(
|
|
253
|
+
scheme=split.scheme,
|
|
254
|
+
netloc=netloc,
|
|
255
|
+
path=split.path or "/",
|
|
256
|
+
query="",
|
|
257
|
+
fragment="",
|
|
258
|
+
)
|
|
259
|
+
return urlunsplit(redacted)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _normalized_content_type(raw_value: str | None) -> str:
|
|
263
|
+
value = str(raw_value or "").strip().lower()
|
|
264
|
+
if not value:
|
|
265
|
+
return ""
|
|
266
|
+
return value.split(";", 1)[0].strip()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _response_body_snippet(response: httpx.Response) -> str:
|
|
270
|
+
try:
|
|
271
|
+
response.read()
|
|
272
|
+
except Exception:
|
|
273
|
+
return "<unable to read response body>"
|
|
274
|
+
try:
|
|
275
|
+
body = response.text
|
|
276
|
+
except Exception:
|
|
277
|
+
return "<unable to decode response body>"
|
|
278
|
+
if len(body) > _SAFE_ERROR_BODY_CHAR_LIMIT:
|
|
279
|
+
body = body[:_SAFE_ERROR_BODY_CHAR_LIMIT] + "...(truncated)"
|
|
280
|
+
return body
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _format_http_error(
|
|
284
|
+
*,
|
|
285
|
+
server: ResolvedMcpServer,
|
|
286
|
+
message: str,
|
|
287
|
+
) -> str:
|
|
288
|
+
return f"MCP HTTP server '{server.id}' (url '{_redacted_url(server.url or '')}'): {message}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _iter_sse_events(response: httpx.Response) -> Iterator[_SseEvent]:
|
|
292
|
+
parser = _SseEventParser()
|
|
293
|
+
try:
|
|
294
|
+
raw_lines = response.iter_lines()
|
|
295
|
+
except Exception as exc: # noqa: BLE001
|
|
296
|
+
raise McpHttpTransportProtocolError("failed to read HTTP event stream") from exc
|
|
297
|
+
|
|
298
|
+
for raw_line in raw_lines:
|
|
299
|
+
if isinstance(raw_line, bytes):
|
|
300
|
+
line = raw_line.decode("utf-8")
|
|
301
|
+
else:
|
|
302
|
+
line = str(raw_line)
|
|
303
|
+
event = parser.feed_line(line)
|
|
304
|
+
if event is not None:
|
|
305
|
+
yield event
|
|
306
|
+
event = parser.finish_eof()
|
|
307
|
+
if event is not None:
|
|
308
|
+
yield event
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class McpHttpTransport:
|
|
312
|
+
def __init__(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
server: ResolvedMcpServer,
|
|
316
|
+
workspace_root: Path,
|
|
317
|
+
client: httpx.Client | None = None,
|
|
318
|
+
server_request_context: McpServerRequestContext | None = None,
|
|
319
|
+
server_request_handler: McpServerRequestHandler | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
if server.transport != "http":
|
|
322
|
+
raise McpHttpTransportError(f"MCP server '{server.id}' is not an HTTP transport.")
|
|
323
|
+
if server.url is None:
|
|
324
|
+
raise McpHttpTransportError(f"MCP server '{server.id}' is missing an HTTP URL.")
|
|
325
|
+
self.server = server
|
|
326
|
+
self.workspace_root = workspace_root.resolve()
|
|
327
|
+
self._server_request_context = server_request_context
|
|
328
|
+
self._server_request_handler = server_request_handler
|
|
329
|
+
self._id_generator = JsonRpcIdGenerator()
|
|
330
|
+
self._notifications: queue.Queue[JsonRpcNotification] = queue.Queue()
|
|
331
|
+
self._client = client or httpx.Client(follow_redirects=False)
|
|
332
|
+
self._owns_client = client is None
|
|
333
|
+
self._closed = False
|
|
334
|
+
self._session_id: str | None = None
|
|
335
|
+
self._negotiated_protocol_version: str | None = None
|
|
336
|
+
self._oauth_metadata: McpAuthorizationServerMetadata | None = None
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def closed(self) -> bool:
|
|
340
|
+
return self._closed
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def session_negotiated(self) -> bool:
|
|
344
|
+
return bool(self._session_id)
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def negotiated_protocol_version(self) -> str | None:
|
|
348
|
+
return self._negotiated_protocol_version
|
|
349
|
+
|
|
350
|
+
def stderr_tail(self) -> str:
|
|
351
|
+
return ""
|
|
352
|
+
|
|
353
|
+
def reset_session_state(self) -> None:
|
|
354
|
+
self._session_id = None
|
|
355
|
+
self._negotiated_protocol_version = None
|
|
356
|
+
|
|
357
|
+
def set_negotiated_protocol_version(self, protocol_version: str | None) -> None:
|
|
358
|
+
cleaned = str(protocol_version or "").strip()
|
|
359
|
+
self._negotiated_protocol_version = cleaned or None
|
|
360
|
+
|
|
361
|
+
def drain_notifications(self) -> tuple[JsonRpcNotification, ...]:
|
|
362
|
+
notifications: list[JsonRpcNotification] = []
|
|
363
|
+
while True:
|
|
364
|
+
try:
|
|
365
|
+
notifications.append(self._notifications.get_nowait())
|
|
366
|
+
except queue.Empty:
|
|
367
|
+
break
|
|
368
|
+
return tuple(notifications)
|
|
369
|
+
|
|
370
|
+
def _build_headers(self, *, access_token: str | None = None) -> dict[str, str]:
|
|
371
|
+
headers = dict(self.server.headers)
|
|
372
|
+
headers["Accept"] = _HTTP_ACCEPT_HEADER
|
|
373
|
+
headers["Content-Type"] = "application/json"
|
|
374
|
+
headers["User-Agent"] = _USER_AGENT
|
|
375
|
+
if access_token is not None:
|
|
376
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
377
|
+
if self._session_id:
|
|
378
|
+
headers["MCP-Session-Id"] = self._session_id
|
|
379
|
+
if self._negotiated_protocol_version:
|
|
380
|
+
headers["MCP-Protocol-Version"] = self._negotiated_protocol_version
|
|
381
|
+
return headers
|
|
382
|
+
|
|
383
|
+
def _build_error(
|
|
384
|
+
self,
|
|
385
|
+
message: str,
|
|
386
|
+
*,
|
|
387
|
+
exc_type: type[McpHttpTransportError] = McpHttpTransportError,
|
|
388
|
+
) -> McpHttpTransportError:
|
|
389
|
+
return exc_type(_format_http_error(server=self.server, message=message))
|
|
390
|
+
|
|
391
|
+
def _oauth_enabled(self) -> bool:
|
|
392
|
+
return self.server.oauth is not None
|
|
393
|
+
|
|
394
|
+
def _oauth_authorization_server_hint(self) -> str | None:
|
|
395
|
+
if self._oauth_metadata is not None:
|
|
396
|
+
return self._oauth_metadata.issuer or self._oauth_metadata.token_endpoint
|
|
397
|
+
if self.server.oauth is not None and self.server.oauth.authorization_server_url:
|
|
398
|
+
return self.server.oauth.authorization_server_url
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def _clear_stored_oauth_tokens(self) -> None:
|
|
402
|
+
try:
|
|
403
|
+
delete_oauth_token_record(self.server.id)
|
|
404
|
+
except McpOAuthTokenStoreError:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
def _discover_oauth_metadata(
|
|
408
|
+
self,
|
|
409
|
+
*,
|
|
410
|
+
unauthorized_response: httpx.Response | None = None,
|
|
411
|
+
) -> McpAuthorizationServerMetadata:
|
|
412
|
+
if self._oauth_metadata is not None:
|
|
413
|
+
return self._oauth_metadata
|
|
414
|
+
oauth_config = self.server.oauth
|
|
415
|
+
if oauth_config is None:
|
|
416
|
+
raise self._build_error("OAuth is not configured for this HTTP MCP server.")
|
|
417
|
+
metadata = discover_authorization_server_metadata(
|
|
418
|
+
server_id=self.server.id,
|
|
419
|
+
resource_server_url=self.server.url or "",
|
|
420
|
+
unauthorized_response=unauthorized_response,
|
|
421
|
+
authorization_server_url=oauth_config.authorization_server_url,
|
|
422
|
+
timeout_s=min(self.server.call_timeout_s, 10.0),
|
|
423
|
+
client=self._client,
|
|
424
|
+
)
|
|
425
|
+
self._oauth_metadata = metadata
|
|
426
|
+
return metadata
|
|
427
|
+
|
|
428
|
+
def _refresh_oauth_token_record(
|
|
429
|
+
self,
|
|
430
|
+
*,
|
|
431
|
+
current_record: McpOAuthTokenRecord,
|
|
432
|
+
unauthorized_response: httpx.Response | None = None,
|
|
433
|
+
) -> McpOAuthTokenRecord:
|
|
434
|
+
metadata: McpAuthorizationServerMetadata | None = None
|
|
435
|
+
try:
|
|
436
|
+
metadata = self._discover_oauth_metadata(unauthorized_response=unauthorized_response)
|
|
437
|
+
oauth_config = self.server.oauth
|
|
438
|
+
if oauth_config is None:
|
|
439
|
+
raise McpOAuthReLoginRequired(
|
|
440
|
+
"OAuth configuration is missing for this HTTP MCP server.",
|
|
441
|
+
server_id=self.server.id,
|
|
442
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
443
|
+
)
|
|
444
|
+
resolved_scopes = resolve_requested_scopes(
|
|
445
|
+
configured_scopes=oauth_config.scopes,
|
|
446
|
+
challenge_scope=None,
|
|
447
|
+
metadata_scopes_supported=None,
|
|
448
|
+
existing_granted_scopes=current_record.granted_scopes,
|
|
449
|
+
purpose="refresh",
|
|
450
|
+
)
|
|
451
|
+
refreshed = refresh_access_token(
|
|
452
|
+
server_id=self.server.id,
|
|
453
|
+
authorization_server_metadata=metadata,
|
|
454
|
+
resource_server_url=self.server.url or "",
|
|
455
|
+
client_id=oauth_config.client_id,
|
|
456
|
+
refresh_token=current_record.refresh_token or "",
|
|
457
|
+
requested_scopes=resolved_scopes,
|
|
458
|
+
existing_granted_scopes=current_record.granted_scopes,
|
|
459
|
+
timeout_s=min(self.server.call_timeout_s, 10.0),
|
|
460
|
+
client=self._client,
|
|
461
|
+
)
|
|
462
|
+
save_oauth_token_record(self.server.id, refreshed)
|
|
463
|
+
return refreshed
|
|
464
|
+
except (McpOAuthDiscoveryError, McpOAuthTokenExchangeError, McpOAuthTokenStoreError) as exc:
|
|
465
|
+
self._clear_stored_oauth_tokens()
|
|
466
|
+
raise McpOAuthReLoginRequired(
|
|
467
|
+
f"stored OAuth credentials could not be refreshed. Run 'alysis mcp auth login {self.server.id}' again.",
|
|
468
|
+
server_id=self.server.id,
|
|
469
|
+
authorization_server_url=(
|
|
470
|
+
metadata.token_endpoint
|
|
471
|
+
if metadata is not None
|
|
472
|
+
else self._oauth_authorization_server_hint()
|
|
473
|
+
),
|
|
474
|
+
) from exc
|
|
475
|
+
|
|
476
|
+
def _resolve_oauth_token_record(self, *, method: str) -> McpOAuthTokenRecord:
|
|
477
|
+
try:
|
|
478
|
+
record = load_oauth_token_record(self.server.id)
|
|
479
|
+
except McpOAuthTokenStoreError as exc:
|
|
480
|
+
raise McpOAuthReLoginRequired(
|
|
481
|
+
f"failed to read stored OAuth credentials before HTTP MCP '{method}'. Run 'alysis mcp auth login {self.server.id}' again.",
|
|
482
|
+
server_id=self.server.id,
|
|
483
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
484
|
+
) from exc
|
|
485
|
+
if record is None:
|
|
486
|
+
raise McpOAuthAuthRequiredError(
|
|
487
|
+
f"OAuth login is required before HTTP MCP '{method}'. Run 'alysis mcp auth login {self.server.id}'.",
|
|
488
|
+
server_id=self.server.id,
|
|
489
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
490
|
+
)
|
|
491
|
+
if not is_token_expired(record):
|
|
492
|
+
return record
|
|
493
|
+
if not record.refresh_token:
|
|
494
|
+
self._clear_stored_oauth_tokens()
|
|
495
|
+
raise McpOAuthReLoginRequired(
|
|
496
|
+
f"stored OAuth credentials expired before HTTP MCP '{method}' and cannot be refreshed. Run 'alysis mcp auth login {self.server.id}' again.",
|
|
497
|
+
server_id=self.server.id,
|
|
498
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
499
|
+
)
|
|
500
|
+
return self._refresh_oauth_token_record(current_record=record)
|
|
501
|
+
|
|
502
|
+
def _oauth_error_signal(self, response: httpx.Response) -> str | None:
|
|
503
|
+
params = parse_www_authenticate_bearer_challenge(response.headers.get("WWW-Authenticate"))
|
|
504
|
+
challenge_error = str(params.get("error") or "").strip()
|
|
505
|
+
if challenge_error:
|
|
506
|
+
return challenge_error
|
|
507
|
+
try:
|
|
508
|
+
payload = response.json()
|
|
509
|
+
except Exception:
|
|
510
|
+
return None
|
|
511
|
+
if isinstance(payload, dict):
|
|
512
|
+
body_error = payload.get("error")
|
|
513
|
+
if isinstance(body_error, str):
|
|
514
|
+
cleaned = body_error.strip()
|
|
515
|
+
if cleaned:
|
|
516
|
+
return cleaned
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
def _oauth_scope_hint(self, response: httpx.Response) -> str:
|
|
520
|
+
params = parse_www_authenticate_bearer_challenge(response.headers.get("WWW-Authenticate"))
|
|
521
|
+
challenge_scope = str(params.get("scope") or "").strip()
|
|
522
|
+
resolved_scopes = resolve_requested_scopes(
|
|
523
|
+
configured_scopes=None,
|
|
524
|
+
challenge_scope=challenge_scope,
|
|
525
|
+
metadata_scopes_supported=None,
|
|
526
|
+
existing_granted_scopes=None,
|
|
527
|
+
purpose="login",
|
|
528
|
+
)
|
|
529
|
+
if not resolved_scopes:
|
|
530
|
+
return ""
|
|
531
|
+
return f" Server requested scopes: {', '.join(resolved_scopes)}."
|
|
532
|
+
|
|
533
|
+
def _handle_oauth_http_status(
|
|
534
|
+
self,
|
|
535
|
+
response: httpx.Response,
|
|
536
|
+
*,
|
|
537
|
+
method: str,
|
|
538
|
+
oauth_record: McpOAuthTokenRecord | None,
|
|
539
|
+
allow_request_replay: bool,
|
|
540
|
+
oauth_retry_attempted: bool,
|
|
541
|
+
) -> bool:
|
|
542
|
+
if (
|
|
543
|
+
response.status_code == 403
|
|
544
|
+
and self._oauth_error_signal(response) == "insufficient_scope"
|
|
545
|
+
):
|
|
546
|
+
raise McpOAuthInsufficientScopeError(
|
|
547
|
+
f"HTTP MCP '{method}' failed with insufficient_scope.",
|
|
548
|
+
server_id=self.server.id,
|
|
549
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
550
|
+
)
|
|
551
|
+
if response.status_code != 401:
|
|
552
|
+
return False
|
|
553
|
+
scope_hint = self._oauth_scope_hint(response)
|
|
554
|
+
if oauth_record is None:
|
|
555
|
+
raise McpOAuthAuthRequiredError(
|
|
556
|
+
f"OAuth login is required before HTTP MCP '{method}'.{scope_hint} Run 'alysis mcp auth login {self.server.id}'.",
|
|
557
|
+
server_id=self.server.id,
|
|
558
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
559
|
+
)
|
|
560
|
+
if not oauth_record.refresh_token:
|
|
561
|
+
self._clear_stored_oauth_tokens()
|
|
562
|
+
raise McpOAuthReLoginRequired(
|
|
563
|
+
f"HTTP MCP '{method}' returned 401 and stored OAuth credentials cannot be refreshed.{scope_hint} Run 'alysis mcp auth login {self.server.id}' again.",
|
|
564
|
+
server_id=self.server.id,
|
|
565
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
566
|
+
)
|
|
567
|
+
if allow_request_replay:
|
|
568
|
+
if oauth_retry_attempted:
|
|
569
|
+
self._clear_stored_oauth_tokens()
|
|
570
|
+
raise McpOAuthReLoginRequired(
|
|
571
|
+
f"HTTP MCP '{method}' still returned 401 after one OAuth refresh and retry.{scope_hint} Run 'alysis mcp auth login {self.server.id}' again.",
|
|
572
|
+
server_id=self.server.id,
|
|
573
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
574
|
+
)
|
|
575
|
+
self._refresh_oauth_token_record(
|
|
576
|
+
current_record=oauth_record,
|
|
577
|
+
unauthorized_response=response,
|
|
578
|
+
)
|
|
579
|
+
return True
|
|
580
|
+
self._refresh_oauth_token_record(
|
|
581
|
+
current_record=oauth_record,
|
|
582
|
+
unauthorized_response=response,
|
|
583
|
+
)
|
|
584
|
+
raise McpOAuthAuthRequiredError(
|
|
585
|
+
f"HTTP MCP '{method}' returned 401.{scope_hint} OAuth credentials were refreshed, but the active request was not automatically retried because it may be side-effectful. Retry the request manually.",
|
|
586
|
+
server_id=self.server.id,
|
|
587
|
+
authorization_server_url=self._oauth_authorization_server_hint(),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
def _capture_session_headers(self, response: httpx.Response, *, method: str) -> None:
|
|
591
|
+
if method != "initialize":
|
|
592
|
+
return
|
|
593
|
+
session_id = str(response.headers.get("MCP-Session-Id") or "").strip()
|
|
594
|
+
if session_id:
|
|
595
|
+
self._session_id = session_id
|
|
596
|
+
|
|
597
|
+
def _raise_for_http_status(
|
|
598
|
+
self,
|
|
599
|
+
response: httpx.Response,
|
|
600
|
+
*,
|
|
601
|
+
method: str,
|
|
602
|
+
had_session_id: bool,
|
|
603
|
+
) -> None:
|
|
604
|
+
status_code = response.status_code
|
|
605
|
+
if 200 <= status_code < 300:
|
|
606
|
+
return
|
|
607
|
+
body_snippet = _response_body_snippet(response).strip()
|
|
608
|
+
body_suffix = f": {body_snippet}" if body_snippet else ""
|
|
609
|
+
if status_code == 401:
|
|
610
|
+
raise self._build_error(
|
|
611
|
+
"server requires HTTP MCP authentication. Configure static headers for this "
|
|
612
|
+
"server, or use 'alysis mcp auth login <server_id>' for OAuth-enabled "
|
|
613
|
+
f"HTTP MCP servers{body_suffix}",
|
|
614
|
+
exc_type=McpHttpTransportAuthRequiredError,
|
|
615
|
+
)
|
|
616
|
+
if status_code == 404 and had_session_id:
|
|
617
|
+
raise self._build_error(
|
|
618
|
+
f"HTTP MCP session expired during '{method}'",
|
|
619
|
+
exc_type=McpHttpTransportSessionExpiredError,
|
|
620
|
+
)
|
|
621
|
+
if method == "initialize" and not had_session_id and status_code in {400, 404, 405}:
|
|
622
|
+
raise self._build_error(
|
|
623
|
+
"initialize POST was rejected by the HTTP MCP server. The server may require "
|
|
624
|
+
"legacy HTTP+SSE fallback behavior, which is intentionally out of scope in this "
|
|
625
|
+
f"slice{body_suffix}",
|
|
626
|
+
exc_type=McpHttpTransportProtocolError,
|
|
627
|
+
)
|
|
628
|
+
if status_code >= 500:
|
|
629
|
+
raise self._build_error(
|
|
630
|
+
f"HTTP MCP server returned {status_code}{body_suffix}",
|
|
631
|
+
exc_type=McpHttpTransportRemoteError,
|
|
632
|
+
)
|
|
633
|
+
raise self._build_error(
|
|
634
|
+
f"HTTP MCP request '{method}' failed with status {status_code}{body_suffix}",
|
|
635
|
+
exc_type=McpHttpTransportProtocolError,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def _process_jsonrpc_message(
|
|
639
|
+
self,
|
|
640
|
+
message: JsonRpcNotification | JsonRpcRequest | JsonRpcResponse,
|
|
641
|
+
*,
|
|
642
|
+
method: str,
|
|
643
|
+
expected_response_id: int | str | None,
|
|
644
|
+
response_received: bool,
|
|
645
|
+
server_request_timeout_s: float | None = None,
|
|
646
|
+
allow_server_requests: bool = False,
|
|
647
|
+
) -> tuple[JsonRpcResponse | None, bool]:
|
|
648
|
+
if isinstance(message, JsonRpcNotification):
|
|
649
|
+
if response_received:
|
|
650
|
+
raise self._build_error(
|
|
651
|
+
"received an additional JSON-RPC message after the matching HTTP response "
|
|
652
|
+
f"for '{method}'",
|
|
653
|
+
exc_type=McpHttpTransportProtocolError,
|
|
654
|
+
)
|
|
655
|
+
self._notifications.put(message)
|
|
656
|
+
return None, response_received
|
|
657
|
+
if isinstance(message, JsonRpcRequest):
|
|
658
|
+
if not allow_server_requests:
|
|
659
|
+
if response_received:
|
|
660
|
+
raise self._build_error(
|
|
661
|
+
f"received unsupported server-initiated request '{message.method}' during "
|
|
662
|
+
f"HTTP MCP '{method}' follow-up",
|
|
663
|
+
exc_type=McpHttpTransportProtocolError,
|
|
664
|
+
)
|
|
665
|
+
raise self._build_error(
|
|
666
|
+
f"received unsupported server-initiated request '{message.method}' during "
|
|
667
|
+
f"HTTP MCP '{method}'",
|
|
668
|
+
exc_type=McpHttpTransportProtocolError,
|
|
669
|
+
)
|
|
670
|
+
self._handle_server_request_message(
|
|
671
|
+
message,
|
|
672
|
+
method=method,
|
|
673
|
+
timeout_s=server_request_timeout_s,
|
|
674
|
+
)
|
|
675
|
+
return None, response_received
|
|
676
|
+
if expected_response_id is None:
|
|
677
|
+
raise self._build_error(
|
|
678
|
+
f"received an unexpected JSON-RPC response while handling HTTP notification "
|
|
679
|
+
f"'{method}'",
|
|
680
|
+
exc_type=McpHttpTransportProtocolError,
|
|
681
|
+
)
|
|
682
|
+
if response_received:
|
|
683
|
+
raise self._build_error(
|
|
684
|
+
f"received multiple JSON-RPC responses for one HTTP MCP '{method}' request",
|
|
685
|
+
exc_type=McpHttpTransportProtocolError,
|
|
686
|
+
)
|
|
687
|
+
if message.id != expected_response_id:
|
|
688
|
+
raise self._build_error(
|
|
689
|
+
f"received JSON-RPC response id {message.id!r} while waiting for "
|
|
690
|
+
f"{expected_response_id!r} during HTTP MCP '{method}'",
|
|
691
|
+
exc_type=McpHttpTransportProtocolError,
|
|
692
|
+
)
|
|
693
|
+
return message, True
|
|
694
|
+
|
|
695
|
+
def _post_server_request_response(
|
|
696
|
+
self,
|
|
697
|
+
*,
|
|
698
|
+
payload: dict[str, Any],
|
|
699
|
+
request_method: str,
|
|
700
|
+
timeout_s: float,
|
|
701
|
+
) -> None:
|
|
702
|
+
try:
|
|
703
|
+
self._send_jsonrpc_message(
|
|
704
|
+
payload=payload,
|
|
705
|
+
method=f"{request_method} response",
|
|
706
|
+
timeout_s=timeout_s,
|
|
707
|
+
expected_response_id=None,
|
|
708
|
+
)
|
|
709
|
+
except McpHttpTransportError as exc:
|
|
710
|
+
raise self._build_error(
|
|
711
|
+
f"failed to POST JSON-RPC response for server-initiated request '{request_method}'",
|
|
712
|
+
exc_type=type(exc),
|
|
713
|
+
) from exc
|
|
714
|
+
|
|
715
|
+
def _handle_server_request_message(
|
|
716
|
+
self,
|
|
717
|
+
message: JsonRpcRequest,
|
|
718
|
+
*,
|
|
719
|
+
method: str,
|
|
720
|
+
timeout_s: float | None,
|
|
721
|
+
) -> None:
|
|
722
|
+
if self._server_request_handler is None or self._server_request_context is None:
|
|
723
|
+
raise self._build_error(
|
|
724
|
+
f"received unsupported server-initiated request '{message.method}' during "
|
|
725
|
+
f"HTTP MCP '{method}'",
|
|
726
|
+
exc_type=McpHttpTransportProtocolError,
|
|
727
|
+
)
|
|
728
|
+
request_timeout_s = timeout_s or self.server.call_timeout_s
|
|
729
|
+
try:
|
|
730
|
+
result = self._server_request_handler.handle_request(
|
|
731
|
+
context=self._server_request_context,
|
|
732
|
+
method=message.method,
|
|
733
|
+
request_id=message.id,
|
|
734
|
+
params=message.params,
|
|
735
|
+
)
|
|
736
|
+
except McpUnsupportedServerRequestError:
|
|
737
|
+
raise self._build_error(
|
|
738
|
+
f"received unsupported server-initiated request '{message.method}' during "
|
|
739
|
+
f"HTTP MCP '{method}'",
|
|
740
|
+
exc_type=McpHttpTransportProtocolError,
|
|
741
|
+
) from None
|
|
742
|
+
except McpServerRequestHandlerError as exc:
|
|
743
|
+
self._post_server_request_response(
|
|
744
|
+
payload={
|
|
745
|
+
"jsonrpc": "2.0",
|
|
746
|
+
"id": message.id,
|
|
747
|
+
"error": {
|
|
748
|
+
"code": exc.code,
|
|
749
|
+
"message": exc.message,
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
request_method=message.method,
|
|
753
|
+
timeout_s=request_timeout_s,
|
|
754
|
+
)
|
|
755
|
+
raise self._build_error(
|
|
756
|
+
f"failed to handle server-initiated request '{message.method}' during "
|
|
757
|
+
f"HTTP MCP '{method}'",
|
|
758
|
+
exc_type=McpHttpTransportProtocolError,
|
|
759
|
+
) from exc
|
|
760
|
+
except Exception as exc:
|
|
761
|
+
self._post_server_request_response(
|
|
762
|
+
payload={
|
|
763
|
+
"jsonrpc": "2.0",
|
|
764
|
+
"id": message.id,
|
|
765
|
+
"error": {
|
|
766
|
+
"code": -32603,
|
|
767
|
+
"message": (
|
|
768
|
+
"Internal MCP host error while handling server-initiated request."
|
|
769
|
+
),
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
request_method=message.method,
|
|
773
|
+
timeout_s=request_timeout_s,
|
|
774
|
+
)
|
|
775
|
+
raise self._build_error(
|
|
776
|
+
f"failed to handle server-initiated request '{message.method}' during "
|
|
777
|
+
f"HTTP MCP '{method}'",
|
|
778
|
+
exc_type=McpHttpTransportProtocolError,
|
|
779
|
+
) from exc
|
|
780
|
+
self._post_server_request_response(
|
|
781
|
+
payload=build_jsonrpc_result_response(
|
|
782
|
+
request_id=message.id,
|
|
783
|
+
result=result,
|
|
784
|
+
),
|
|
785
|
+
request_method=message.method,
|
|
786
|
+
timeout_s=request_timeout_s,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
def _parse_single_jsonrpc_payload(
|
|
790
|
+
self,
|
|
791
|
+
raw_text: str,
|
|
792
|
+
*,
|
|
793
|
+
method: str,
|
|
794
|
+
) -> JsonRpcNotification | JsonRpcRequest | JsonRpcResponse:
|
|
795
|
+
try:
|
|
796
|
+
messages = parse_jsonrpc_line(raw_text)
|
|
797
|
+
except JsonRpcProtocolError as exc:
|
|
798
|
+
raise self._build_error(
|
|
799
|
+
f"malformed JSON-RPC payload during HTTP MCP '{method}': {exc}",
|
|
800
|
+
exc_type=McpHttpTransportProtocolError,
|
|
801
|
+
) from exc
|
|
802
|
+
if len(messages) != 1:
|
|
803
|
+
raise self._build_error(
|
|
804
|
+
f"JSON-RPC batch payloads are not supported for HTTP MCP '{method}'",
|
|
805
|
+
exc_type=McpHttpTransportProtocolError,
|
|
806
|
+
)
|
|
807
|
+
return messages[0]
|
|
808
|
+
|
|
809
|
+
def _handle_application_json(
|
|
810
|
+
self,
|
|
811
|
+
response: httpx.Response,
|
|
812
|
+
*,
|
|
813
|
+
method: str,
|
|
814
|
+
expected_response_id: int | str | None,
|
|
815
|
+
) -> JsonRpcResponse | None:
|
|
816
|
+
try:
|
|
817
|
+
response.read()
|
|
818
|
+
raw_text = response.text
|
|
819
|
+
except Exception as exc: # noqa: BLE001
|
|
820
|
+
raise self._build_error(
|
|
821
|
+
f"failed to decode HTTP response body for '{method}'",
|
|
822
|
+
exc_type=McpHttpTransportProtocolError,
|
|
823
|
+
) from exc
|
|
824
|
+
if not raw_text.strip():
|
|
825
|
+
if expected_response_id is None:
|
|
826
|
+
return None
|
|
827
|
+
raise self._build_error(
|
|
828
|
+
f"HTTP MCP '{method}' returned an empty JSON body",
|
|
829
|
+
exc_type=McpHttpTransportProtocolError,
|
|
830
|
+
)
|
|
831
|
+
message = self._parse_single_jsonrpc_payload(raw_text, method=method)
|
|
832
|
+
parsed_response, response_received = self._process_jsonrpc_message(
|
|
833
|
+
message,
|
|
834
|
+
method=method,
|
|
835
|
+
expected_response_id=expected_response_id,
|
|
836
|
+
response_received=False,
|
|
837
|
+
server_request_timeout_s=None,
|
|
838
|
+
allow_server_requests=False,
|
|
839
|
+
)
|
|
840
|
+
if expected_response_id is None:
|
|
841
|
+
return None
|
|
842
|
+
if not response_received or parsed_response is None:
|
|
843
|
+
raise self._build_error(
|
|
844
|
+
f"HTTP MCP '{method}' completed without the matching JSON-RPC response",
|
|
845
|
+
exc_type=McpHttpTransportProtocolError,
|
|
846
|
+
)
|
|
847
|
+
return parsed_response
|
|
848
|
+
|
|
849
|
+
def _raise_sse_reader_error(
|
|
850
|
+
self,
|
|
851
|
+
*,
|
|
852
|
+
method: str,
|
|
853
|
+
timeout_s: float,
|
|
854
|
+
error: BaseException,
|
|
855
|
+
) -> None:
|
|
856
|
+
if isinstance(error, McpHttpTransportError):
|
|
857
|
+
raise error
|
|
858
|
+
if isinstance(error, httpx.TimeoutException):
|
|
859
|
+
raise self._build_error(
|
|
860
|
+
f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
|
|
861
|
+
exc_type=McpHttpTransportTimeoutError,
|
|
862
|
+
) from error
|
|
863
|
+
if isinstance(error, httpx.HTTPError):
|
|
864
|
+
raise self._build_error(
|
|
865
|
+
f"HTTP MCP '{method}' event stream failed: {error}",
|
|
866
|
+
exc_type=McpHttpTransportError,
|
|
867
|
+
) from error
|
|
868
|
+
raise self._build_error(
|
|
869
|
+
f"failed to read HTTP event stream for '{method}': {error}",
|
|
870
|
+
exc_type=McpHttpTransportError,
|
|
871
|
+
) from error
|
|
872
|
+
|
|
873
|
+
def _start_sse_reader(
|
|
874
|
+
self,
|
|
875
|
+
response: httpx.Response,
|
|
876
|
+
) -> tuple[queue.Queue[_SseReaderItem], threading.Thread, _SseReaderState]:
|
|
877
|
+
reader_items: queue.Queue[_SseReaderItem] = queue.Queue()
|
|
878
|
+
reader_state = _SseReaderState()
|
|
879
|
+
|
|
880
|
+
def _reader() -> None:
|
|
881
|
+
parser = _SseEventParser()
|
|
882
|
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
883
|
+
text_buffer = ""
|
|
884
|
+
|
|
885
|
+
def _queue_event(
|
|
886
|
+
*,
|
|
887
|
+
event: _SseEvent,
|
|
888
|
+
activity_sequence: int,
|
|
889
|
+
activity_monotonic: float,
|
|
890
|
+
) -> None:
|
|
891
|
+
reader_items.put(
|
|
892
|
+
_SseReaderItem(
|
|
893
|
+
kind="event",
|
|
894
|
+
activity_sequence=activity_sequence,
|
|
895
|
+
activity_monotonic=activity_monotonic,
|
|
896
|
+
event=event,
|
|
897
|
+
)
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
try:
|
|
901
|
+
try:
|
|
902
|
+
body_chunks = response.iter_bytes()
|
|
903
|
+
except Exception as exc: # noqa: BLE001
|
|
904
|
+
raise McpHttpTransportProtocolError("failed to read HTTP event stream") from exc
|
|
905
|
+
for body_chunk in body_chunks:
|
|
906
|
+
if not body_chunk:
|
|
907
|
+
continue
|
|
908
|
+
activity_sequence, activity_monotonic = reader_state.record_activity(
|
|
909
|
+
event_in_progress=True
|
|
910
|
+
)
|
|
911
|
+
try:
|
|
912
|
+
# Keep HTTP content decoding enabled for POST-SSE so compressed
|
|
913
|
+
# event streams from servers or intermediaries still work.
|
|
914
|
+
text_buffer += decoder.decode(body_chunk)
|
|
915
|
+
except UnicodeDecodeError as exc:
|
|
916
|
+
raise McpHttpTransportProtocolError(
|
|
917
|
+
"failed to decode HTTP event stream as UTF-8"
|
|
918
|
+
) from exc
|
|
919
|
+
while True:
|
|
920
|
+
line, text_buffer = _extract_sse_line(text_buffer)
|
|
921
|
+
if line is None:
|
|
922
|
+
break
|
|
923
|
+
event = parser.feed_line(line)
|
|
924
|
+
reader_state.set_event_in_progress(
|
|
925
|
+
event_in_progress=parser.event_in_progress(
|
|
926
|
+
has_partial_line=bool(text_buffer)
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
if event is not None:
|
|
930
|
+
_queue_event(
|
|
931
|
+
event=event,
|
|
932
|
+
activity_sequence=activity_sequence,
|
|
933
|
+
activity_monotonic=activity_monotonic,
|
|
934
|
+
)
|
|
935
|
+
reader_state.set_event_in_progress(
|
|
936
|
+
event_in_progress=parser.event_in_progress(
|
|
937
|
+
has_partial_line=bool(text_buffer)
|
|
938
|
+
)
|
|
939
|
+
)
|
|
940
|
+
try:
|
|
941
|
+
text_buffer += decoder.decode(b"", final=True)
|
|
942
|
+
except UnicodeDecodeError as exc:
|
|
943
|
+
raise McpHttpTransportProtocolError(
|
|
944
|
+
"failed to decode HTTP event stream as UTF-8"
|
|
945
|
+
) from exc
|
|
946
|
+
eof_activity_sequence = 0
|
|
947
|
+
eof_activity_monotonic = 0.0
|
|
948
|
+
if text_buffer:
|
|
949
|
+
eof_activity_sequence, eof_activity_monotonic = reader_state.record_activity(
|
|
950
|
+
event_in_progress=True
|
|
951
|
+
)
|
|
952
|
+
while True:
|
|
953
|
+
line, text_buffer = _extract_sse_line(text_buffer, eof=True)
|
|
954
|
+
if line is None:
|
|
955
|
+
break
|
|
956
|
+
event = parser.feed_line(line)
|
|
957
|
+
if event is not None:
|
|
958
|
+
_queue_event(
|
|
959
|
+
event=event,
|
|
960
|
+
activity_sequence=eof_activity_sequence,
|
|
961
|
+
activity_monotonic=eof_activity_monotonic,
|
|
962
|
+
)
|
|
963
|
+
if text_buffer:
|
|
964
|
+
event = parser.feed_line(text_buffer)
|
|
965
|
+
text_buffer = ""
|
|
966
|
+
if event is not None:
|
|
967
|
+
_queue_event(
|
|
968
|
+
event=event,
|
|
969
|
+
activity_sequence=eof_activity_sequence,
|
|
970
|
+
activity_monotonic=eof_activity_monotonic,
|
|
971
|
+
)
|
|
972
|
+
final_event = parser.finish_eof()
|
|
973
|
+
if final_event is not None:
|
|
974
|
+
if eof_activity_sequence == 0:
|
|
975
|
+
eof_activity_sequence, eof_activity_monotonic = (
|
|
976
|
+
reader_state.record_activity(event_in_progress=False)
|
|
977
|
+
)
|
|
978
|
+
else:
|
|
979
|
+
reader_state.set_event_in_progress(event_in_progress=False)
|
|
980
|
+
_queue_event(
|
|
981
|
+
event=final_event,
|
|
982
|
+
activity_sequence=eof_activity_sequence,
|
|
983
|
+
activity_monotonic=eof_activity_monotonic,
|
|
984
|
+
)
|
|
985
|
+
except BaseException as exc: # noqa: BLE001
|
|
986
|
+
activity_sequence, activity_monotonic = reader_state.record_activity(
|
|
987
|
+
event_in_progress=False
|
|
988
|
+
)
|
|
989
|
+
reader_items.put(
|
|
990
|
+
_SseReaderItem(
|
|
991
|
+
kind="error",
|
|
992
|
+
activity_sequence=activity_sequence,
|
|
993
|
+
activity_monotonic=activity_monotonic,
|
|
994
|
+
error=exc,
|
|
995
|
+
)
|
|
996
|
+
)
|
|
997
|
+
return
|
|
998
|
+
activity_sequence, activity_monotonic = reader_state.record_activity(
|
|
999
|
+
event_in_progress=False
|
|
1000
|
+
)
|
|
1001
|
+
reader_items.put(
|
|
1002
|
+
_SseReaderItem(
|
|
1003
|
+
kind="closed",
|
|
1004
|
+
activity_sequence=activity_sequence,
|
|
1005
|
+
activity_monotonic=activity_monotonic,
|
|
1006
|
+
)
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
reader_thread = threading.Thread(
|
|
1010
|
+
target=_reader,
|
|
1011
|
+
name=f"mcp-http-sse-{self.server.id}",
|
|
1012
|
+
daemon=True,
|
|
1013
|
+
)
|
|
1014
|
+
reader_thread.start()
|
|
1015
|
+
return reader_items, reader_thread, reader_state
|
|
1016
|
+
|
|
1017
|
+
def _handle_open_ended_follow_up_message(
|
|
1018
|
+
self,
|
|
1019
|
+
message: JsonRpcNotification | JsonRpcRequest | JsonRpcResponse,
|
|
1020
|
+
*,
|
|
1021
|
+
method: str,
|
|
1022
|
+
) -> None:
|
|
1023
|
+
if isinstance(message, JsonRpcNotification):
|
|
1024
|
+
self._notifications.put(message)
|
|
1025
|
+
return
|
|
1026
|
+
if isinstance(message, JsonRpcRequest):
|
|
1027
|
+
raise self._build_error(
|
|
1028
|
+
f"received unsupported server-initiated request '{message.method}' during "
|
|
1029
|
+
f"HTTP MCP '{method}' follow-up",
|
|
1030
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1031
|
+
)
|
|
1032
|
+
raise self._build_error(
|
|
1033
|
+
"received an additional JSON-RPC message after the matching HTTP response "
|
|
1034
|
+
f"for '{method}'",
|
|
1035
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
def _handle_open_ended_request_event_stream(
|
|
1039
|
+
self,
|
|
1040
|
+
response: httpx.Response,
|
|
1041
|
+
*,
|
|
1042
|
+
method: str,
|
|
1043
|
+
expected_response_id: int | str,
|
|
1044
|
+
timeout_s: float,
|
|
1045
|
+
) -> JsonRpcResponse:
|
|
1046
|
+
response_deadline = time.monotonic() + timeout_s
|
|
1047
|
+
last_reader_activity_monotonic: float | None = None
|
|
1048
|
+
last_observed_activity_sequence = 0
|
|
1049
|
+
reader_event_in_progress = False
|
|
1050
|
+
parsed_response: JsonRpcResponse | None = None
|
|
1051
|
+
reader_items, reader_thread, reader_state = self._start_sse_reader(response)
|
|
1052
|
+
try:
|
|
1053
|
+
while True:
|
|
1054
|
+
now = time.monotonic()
|
|
1055
|
+
if parsed_response is None:
|
|
1056
|
+
wait_timeout = response_deadline - now
|
|
1057
|
+
if wait_timeout <= 0:
|
|
1058
|
+
raise self._build_error(
|
|
1059
|
+
f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
|
|
1060
|
+
exc_type=McpHttpTransportTimeoutError,
|
|
1061
|
+
)
|
|
1062
|
+
else:
|
|
1063
|
+
snapshot = reader_state.snapshot()
|
|
1064
|
+
if snapshot.activity_sequence > last_observed_activity_sequence:
|
|
1065
|
+
last_observed_activity_sequence = snapshot.activity_sequence
|
|
1066
|
+
last_reader_activity_monotonic = snapshot.activity_monotonic
|
|
1067
|
+
reader_event_in_progress = snapshot.event_in_progress
|
|
1068
|
+
assert last_reader_activity_monotonic is not None
|
|
1069
|
+
if now >= response_deadline:
|
|
1070
|
+
raise self._build_error(
|
|
1071
|
+
f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
|
|
1072
|
+
exc_type=McpHttpTransportTimeoutError,
|
|
1073
|
+
)
|
|
1074
|
+
# Open-ended POST-SSE success is gated by raw reader progress, not only by
|
|
1075
|
+
# completed events. A partially read trailing SSE event keeps follow-up active
|
|
1076
|
+
# until that event either completes or the request deadline expires.
|
|
1077
|
+
quiet_deadline = (
|
|
1078
|
+
last_reader_activity_monotonic + _OPEN_ENDED_SSE_FOLLOW_UP_QUIET_WINDOW_S
|
|
1079
|
+
)
|
|
1080
|
+
if not reader_event_in_progress and quiet_deadline <= now:
|
|
1081
|
+
return parsed_response
|
|
1082
|
+
if reader_event_in_progress:
|
|
1083
|
+
wait_timeout = response_deadline - now
|
|
1084
|
+
else:
|
|
1085
|
+
wait_timeout = min(quiet_deadline - now, response_deadline - now)
|
|
1086
|
+
try:
|
|
1087
|
+
item = reader_items.get(timeout=max(wait_timeout, 0.0))
|
|
1088
|
+
except queue.Empty:
|
|
1089
|
+
snapshot = reader_state.snapshot()
|
|
1090
|
+
if snapshot.activity_sequence > last_observed_activity_sequence:
|
|
1091
|
+
last_observed_activity_sequence = snapshot.activity_sequence
|
|
1092
|
+
last_reader_activity_monotonic = snapshot.activity_monotonic
|
|
1093
|
+
reader_event_in_progress = snapshot.event_in_progress
|
|
1094
|
+
continue
|
|
1095
|
+
last_observed_activity_sequence = max(
|
|
1096
|
+
last_observed_activity_sequence,
|
|
1097
|
+
item.activity_sequence,
|
|
1098
|
+
)
|
|
1099
|
+
if item.kind == "closed":
|
|
1100
|
+
if parsed_response is None:
|
|
1101
|
+
raise self._build_error(
|
|
1102
|
+
"HTTP event stream ended before the matching JSON-RPC response "
|
|
1103
|
+
f"arrived for '{method}'",
|
|
1104
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1105
|
+
)
|
|
1106
|
+
last_reader_activity_monotonic = max(
|
|
1107
|
+
last_reader_activity_monotonic or item.activity_monotonic,
|
|
1108
|
+
item.activity_monotonic,
|
|
1109
|
+
)
|
|
1110
|
+
return parsed_response
|
|
1111
|
+
if item.kind == "error":
|
|
1112
|
+
error = item.error or RuntimeError("unknown HTTP event-stream reader failure")
|
|
1113
|
+
self._raise_sse_reader_error(
|
|
1114
|
+
method=method,
|
|
1115
|
+
timeout_s=timeout_s,
|
|
1116
|
+
error=error,
|
|
1117
|
+
)
|
|
1118
|
+
event = item.event
|
|
1119
|
+
if event is None or not event.data.strip():
|
|
1120
|
+
continue
|
|
1121
|
+
last_reader_activity_monotonic = item.activity_monotonic
|
|
1122
|
+
message = self._parse_single_jsonrpc_payload(event.data, method=method)
|
|
1123
|
+
if parsed_response is None:
|
|
1124
|
+
parsed_message, response_received = self._process_jsonrpc_message(
|
|
1125
|
+
message,
|
|
1126
|
+
method=method,
|
|
1127
|
+
expected_response_id=expected_response_id,
|
|
1128
|
+
response_received=False,
|
|
1129
|
+
server_request_timeout_s=max(response_deadline - time.monotonic(), 0.001),
|
|
1130
|
+
allow_server_requests=True,
|
|
1131
|
+
)
|
|
1132
|
+
if parsed_message is None or not response_received:
|
|
1133
|
+
continue
|
|
1134
|
+
parsed_response = parsed_message
|
|
1135
|
+
continue
|
|
1136
|
+
self._handle_open_ended_follow_up_message(message, method=method)
|
|
1137
|
+
finally:
|
|
1138
|
+
response.close()
|
|
1139
|
+
reader_thread.join(timeout=_OPEN_ENDED_SSE_READER_JOIN_TIMEOUT_S)
|
|
1140
|
+
|
|
1141
|
+
def _handle_event_stream(
|
|
1142
|
+
self,
|
|
1143
|
+
response: httpx.Response,
|
|
1144
|
+
*,
|
|
1145
|
+
method: str,
|
|
1146
|
+
expected_response_id: int | str | None,
|
|
1147
|
+
timeout_s: float,
|
|
1148
|
+
) -> JsonRpcResponse | None:
|
|
1149
|
+
has_content_length = response.headers.get("content-length") is not None
|
|
1150
|
+
if not has_content_length and expected_response_id is not None:
|
|
1151
|
+
return self._handle_open_ended_request_event_stream(
|
|
1152
|
+
response,
|
|
1153
|
+
method=method,
|
|
1154
|
+
expected_response_id=expected_response_id,
|
|
1155
|
+
timeout_s=timeout_s,
|
|
1156
|
+
)
|
|
1157
|
+
response_received = False
|
|
1158
|
+
parsed_response: JsonRpcResponse | None = None
|
|
1159
|
+
for event in _iter_sse_events(response):
|
|
1160
|
+
if not event.data.strip():
|
|
1161
|
+
continue
|
|
1162
|
+
message = self._parse_single_jsonrpc_payload(event.data, method=method)
|
|
1163
|
+
parsed_message, response_received = self._process_jsonrpc_message(
|
|
1164
|
+
message,
|
|
1165
|
+
method=method,
|
|
1166
|
+
expected_response_id=expected_response_id,
|
|
1167
|
+
response_received=response_received,
|
|
1168
|
+
server_request_timeout_s=timeout_s,
|
|
1169
|
+
allow_server_requests=expected_response_id is not None and not response_received,
|
|
1170
|
+
)
|
|
1171
|
+
if parsed_message is not None:
|
|
1172
|
+
parsed_response = parsed_message
|
|
1173
|
+
if expected_response_id is None:
|
|
1174
|
+
return None
|
|
1175
|
+
if parsed_response is None:
|
|
1176
|
+
raise self._build_error(
|
|
1177
|
+
f"HTTP event stream ended before the matching JSON-RPC response arrived for "
|
|
1178
|
+
f"'{method}'",
|
|
1179
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1180
|
+
)
|
|
1181
|
+
return parsed_response
|
|
1182
|
+
|
|
1183
|
+
def _send_jsonrpc_message(
|
|
1184
|
+
self,
|
|
1185
|
+
*,
|
|
1186
|
+
payload: dict[str, Any],
|
|
1187
|
+
method: str,
|
|
1188
|
+
timeout_s: float,
|
|
1189
|
+
expected_response_id: int | str | None,
|
|
1190
|
+
) -> JsonRpcResponse | None:
|
|
1191
|
+
if self._closed:
|
|
1192
|
+
raise self._build_error("transport is already closed.")
|
|
1193
|
+
request_body = encode_jsonrpc_message(payload)
|
|
1194
|
+
had_session_id = bool(self._session_id)
|
|
1195
|
+
allow_request_replay = method != "tools/call"
|
|
1196
|
+
oauth_retry_attempted = False
|
|
1197
|
+
try:
|
|
1198
|
+
while True:
|
|
1199
|
+
oauth_record: McpOAuthTokenRecord | None = None
|
|
1200
|
+
access_token: str | None = None
|
|
1201
|
+
if self._oauth_enabled():
|
|
1202
|
+
oauth_record = self._resolve_oauth_token_record(method=method)
|
|
1203
|
+
access_token = oauth_record.access_token
|
|
1204
|
+
request_headers = self._build_headers(access_token=access_token)
|
|
1205
|
+
with self._client.stream(
|
|
1206
|
+
"POST",
|
|
1207
|
+
self.server.url,
|
|
1208
|
+
headers=request_headers,
|
|
1209
|
+
content=request_body.encode("utf-8"),
|
|
1210
|
+
timeout=timeout_s,
|
|
1211
|
+
) as response:
|
|
1212
|
+
if self._oauth_enabled() and self._handle_oauth_http_status(
|
|
1213
|
+
response,
|
|
1214
|
+
method=method,
|
|
1215
|
+
oauth_record=oauth_record,
|
|
1216
|
+
allow_request_replay=allow_request_replay,
|
|
1217
|
+
oauth_retry_attempted=oauth_retry_attempted,
|
|
1218
|
+
):
|
|
1219
|
+
oauth_retry_attempted = True
|
|
1220
|
+
continue
|
|
1221
|
+
self._raise_for_http_status(
|
|
1222
|
+
response,
|
|
1223
|
+
method=method,
|
|
1224
|
+
had_session_id=had_session_id,
|
|
1225
|
+
)
|
|
1226
|
+
self._capture_session_headers(response, method=method)
|
|
1227
|
+
content_type = _normalized_content_type(response.headers.get("content-type"))
|
|
1228
|
+
if not content_type:
|
|
1229
|
+
if expected_response_id is None and response.status_code in {202, 204}:
|
|
1230
|
+
return None
|
|
1231
|
+
if expected_response_id is None and not response.read():
|
|
1232
|
+
return None
|
|
1233
|
+
raise self._build_error(
|
|
1234
|
+
f"HTTP MCP '{method}' returned no content type",
|
|
1235
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1236
|
+
)
|
|
1237
|
+
if content_type == "application/json":
|
|
1238
|
+
return self._handle_application_json(
|
|
1239
|
+
response,
|
|
1240
|
+
method=method,
|
|
1241
|
+
expected_response_id=expected_response_id,
|
|
1242
|
+
)
|
|
1243
|
+
if content_type == "text/event-stream":
|
|
1244
|
+
return self._handle_event_stream(
|
|
1245
|
+
response,
|
|
1246
|
+
method=method,
|
|
1247
|
+
expected_response_id=expected_response_id,
|
|
1248
|
+
timeout_s=timeout_s,
|
|
1249
|
+
)
|
|
1250
|
+
raise self._build_error(
|
|
1251
|
+
f"HTTP MCP '{method}' returned unsupported content type {content_type!r}",
|
|
1252
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1253
|
+
)
|
|
1254
|
+
except (McpHttpTransportError, McpOAuthError):
|
|
1255
|
+
raise
|
|
1256
|
+
except httpx.TimeoutException as exc:
|
|
1257
|
+
raise self._build_error(
|
|
1258
|
+
f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
|
|
1259
|
+
exc_type=McpHttpTransportTimeoutError,
|
|
1260
|
+
) from exc
|
|
1261
|
+
except httpx.HTTPError as exc:
|
|
1262
|
+
raise self._build_error(
|
|
1263
|
+
f"HTTP MCP '{method}' request failed: {exc}",
|
|
1264
|
+
exc_type=McpHttpTransportError,
|
|
1265
|
+
) from exc
|
|
1266
|
+
|
|
1267
|
+
def request(
|
|
1268
|
+
self,
|
|
1269
|
+
*,
|
|
1270
|
+
method: str,
|
|
1271
|
+
params: Any | None,
|
|
1272
|
+
timeout_s: float,
|
|
1273
|
+
) -> JsonRpcResponse:
|
|
1274
|
+
request_id = self._id_generator.next()
|
|
1275
|
+
response = self._send_jsonrpc_message(
|
|
1276
|
+
payload=build_jsonrpc_request(
|
|
1277
|
+
request_id=request_id,
|
|
1278
|
+
method=method,
|
|
1279
|
+
params=params,
|
|
1280
|
+
),
|
|
1281
|
+
method=method,
|
|
1282
|
+
timeout_s=timeout_s,
|
|
1283
|
+
expected_response_id=request_id,
|
|
1284
|
+
)
|
|
1285
|
+
if response is None:
|
|
1286
|
+
raise self._build_error(
|
|
1287
|
+
f"HTTP MCP '{method}' completed without the matching JSON-RPC response",
|
|
1288
|
+
exc_type=McpHttpTransportProtocolError,
|
|
1289
|
+
)
|
|
1290
|
+
return response
|
|
1291
|
+
|
|
1292
|
+
def send_notification(
|
|
1293
|
+
self,
|
|
1294
|
+
*,
|
|
1295
|
+
method: str,
|
|
1296
|
+
params: Any | None,
|
|
1297
|
+
completion_timeout_s: float | None = None,
|
|
1298
|
+
) -> None:
|
|
1299
|
+
timeout_s = completion_timeout_s or self.server.call_timeout_s
|
|
1300
|
+
self._send_jsonrpc_message(
|
|
1301
|
+
payload=build_jsonrpc_notification(
|
|
1302
|
+
method=method,
|
|
1303
|
+
params=params,
|
|
1304
|
+
),
|
|
1305
|
+
method=method,
|
|
1306
|
+
timeout_s=timeout_s,
|
|
1307
|
+
expected_response_id=None,
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
def close(self) -> None:
|
|
1311
|
+
if self._closed:
|
|
1312
|
+
return
|
|
1313
|
+
self._closed = True
|
|
1314
|
+
if self._session_id:
|
|
1315
|
+
try:
|
|
1316
|
+
delete_headers = dict(self.server.headers)
|
|
1317
|
+
delete_headers["User-Agent"] = _USER_AGENT
|
|
1318
|
+
if self._oauth_enabled():
|
|
1319
|
+
try:
|
|
1320
|
+
close_record = load_oauth_token_record(self.server.id)
|
|
1321
|
+
except McpOAuthTokenStoreError:
|
|
1322
|
+
close_record = None
|
|
1323
|
+
if close_record is not None:
|
|
1324
|
+
delete_headers["Authorization"] = f"Bearer {close_record.access_token}"
|
|
1325
|
+
delete_headers["MCP-Session-Id"] = self._session_id
|
|
1326
|
+
if self._negotiated_protocol_version:
|
|
1327
|
+
delete_headers["MCP-Protocol-Version"] = self._negotiated_protocol_version
|
|
1328
|
+
response = self._client.delete(
|
|
1329
|
+
self.server.url,
|
|
1330
|
+
headers=delete_headers,
|
|
1331
|
+
timeout=min(self.server.call_timeout_s, 2.0),
|
|
1332
|
+
)
|
|
1333
|
+
if response.status_code not in {200, 202, 204, 404, 405}:
|
|
1334
|
+
response.read()
|
|
1335
|
+
except Exception:
|
|
1336
|
+
pass
|
|
1337
|
+
if self._owns_client:
|
|
1338
|
+
self._client.close()
|