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
alysis_code/mcp/oauth.py
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..safety import SafeHttpError, safe_http_request
|
|
16
|
+
from .errors import McpAuthError, McpConfigError
|
|
17
|
+
from .models import validate_http_url, validate_https_url
|
|
18
|
+
from .oauth_store import McpOAuthTokenRecord, McpOAuthTokenStoreError
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"McpAuthorizationServerMetadata",
|
|
22
|
+
"McpOAuthAuthRequiredError",
|
|
23
|
+
"McpOAuthCallbackError",
|
|
24
|
+
"McpOAuthConfigError",
|
|
25
|
+
"McpOAuthDiscoveryError",
|
|
26
|
+
"McpOAuthError",
|
|
27
|
+
"McpOAuthInsufficientScopeError",
|
|
28
|
+
"McpOAuthReLoginRequired",
|
|
29
|
+
"McpOAuthTokenExchangeError",
|
|
30
|
+
"McpOAuthTokenStoreError",
|
|
31
|
+
"McpProtectedResourceMetadata",
|
|
32
|
+
"_redact_token",
|
|
33
|
+
"build_authorization_url",
|
|
34
|
+
"build_pkce_challenge",
|
|
35
|
+
"canonical_mcp_resource_uri",
|
|
36
|
+
"discover_authorization_server_metadata",
|
|
37
|
+
"discover_authorization_server_metadata_from_url",
|
|
38
|
+
"discover_protected_resource_metadata",
|
|
39
|
+
"exchange_authorization_code",
|
|
40
|
+
"generate_oauth_state",
|
|
41
|
+
"generate_pkce_verifier",
|
|
42
|
+
"is_token_expired",
|
|
43
|
+
"parse_www_authenticate_bearer_challenge",
|
|
44
|
+
"refresh_access_token",
|
|
45
|
+
"resolve_requested_scopes",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
_PKCE_VERIFIER_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
49
|
+
_PKCE_VERIFIER_RE = re.compile(r"^[A-Za-z0-9\-\._~]{43,128}$")
|
|
50
|
+
_BEARER_PARAM_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_-]*)="([^"]*)"')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class McpOAuthError(McpAuthError):
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
message: str,
|
|
57
|
+
*,
|
|
58
|
+
server_id: str,
|
|
59
|
+
authorization_server_url: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.server_id = str(server_id).strip()
|
|
62
|
+
self.authorization_server_origin = _url_origin(authorization_server_url)
|
|
63
|
+
prefix = f"{self.__class__.__name__}: server '{self.server_id}'"
|
|
64
|
+
if self.authorization_server_origin:
|
|
65
|
+
prefix += f" auth server '{self.authorization_server_origin}'"
|
|
66
|
+
cleaned = str(message or "OAuth operation failed.").strip()
|
|
67
|
+
super().__init__(f"{prefix}: {cleaned}", server_id=self.server_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class McpOAuthConfigError(McpOAuthError, McpConfigError):
|
|
71
|
+
error_code = "mcp_oauth_config_error"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class McpOAuthDiscoveryError(McpOAuthError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class McpOAuthAuthRequiredError(McpOAuthError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class McpOAuthReLoginRequired(McpOAuthError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class McpOAuthInsufficientScopeError(McpOAuthError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class McpOAuthCallbackError(McpOAuthError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class McpOAuthTokenExchangeError(McpOAuthError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _McpOAuthDiscoveryLookupError(McpOAuthDiscoveryError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class McpProtectedResourceMetadata:
|
|
104
|
+
authorization_servers: tuple[str, ...]
|
|
105
|
+
resource: str | None = None
|
|
106
|
+
scopes_supported: tuple[str, ...] = field(default_factory=tuple)
|
|
107
|
+
raw_payload: dict[str, Any] = field(repr=False, default_factory=dict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class McpAuthorizationServerMetadata:
|
|
112
|
+
issuer: str
|
|
113
|
+
authorization_endpoint: str
|
|
114
|
+
token_endpoint: str
|
|
115
|
+
code_challenge_methods_supported: tuple[str, ...] = field(default_factory=tuple)
|
|
116
|
+
response_types_supported: tuple[str, ...] = field(default_factory=tuple)
|
|
117
|
+
scopes_supported: tuple[str, ...] = field(default_factory=tuple)
|
|
118
|
+
raw_payload: dict[str, Any] = field(repr=False, default_factory=dict)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _redact_token(value: str | None) -> str:
|
|
122
|
+
if not value:
|
|
123
|
+
return "[redacted:0chars]"
|
|
124
|
+
return f"[redacted:{len(value)}chars]"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _url_origin(value: str | None) -> str | None:
|
|
128
|
+
if not value:
|
|
129
|
+
return None
|
|
130
|
+
split = urlsplit(value)
|
|
131
|
+
if not split.scheme or not split.netloc:
|
|
132
|
+
return None
|
|
133
|
+
netloc = split.netloc
|
|
134
|
+
return urlunsplit(SplitResult(split.scheme, netloc, "", "", ""))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def canonical_mcp_resource_uri(server_url: str) -> str:
|
|
138
|
+
normalized_url = validate_http_url(server_url, field_name="resource_server_url")
|
|
139
|
+
split = urlsplit(normalized_url)
|
|
140
|
+
hostname = str(split.hostname or "").strip()
|
|
141
|
+
if not hostname:
|
|
142
|
+
raise McpOAuthConfigError(
|
|
143
|
+
"resource_server_url must include a valid host.",
|
|
144
|
+
server_id="oauth",
|
|
145
|
+
authorization_server_url=normalized_url,
|
|
146
|
+
)
|
|
147
|
+
normalized_scheme = split.scheme.lower()
|
|
148
|
+
normalized_host = hostname.lower()
|
|
149
|
+
if ":" in normalized_host and not normalized_host.startswith("["):
|
|
150
|
+
normalized_host = f"[{normalized_host}]"
|
|
151
|
+
netloc = normalized_host
|
|
152
|
+
if split.port is not None:
|
|
153
|
+
netloc = f"{netloc}:{split.port}"
|
|
154
|
+
path = split.path or ""
|
|
155
|
+
normalized_path = "" if path == "/" else path
|
|
156
|
+
return urlunsplit(SplitResult(normalized_scheme, netloc, normalized_path, "", ""))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _required_string(
|
|
160
|
+
value: object,
|
|
161
|
+
*,
|
|
162
|
+
field_name: str,
|
|
163
|
+
server_id: str,
|
|
164
|
+
authorization_server_url: str | None = None,
|
|
165
|
+
error_cls: type[McpOAuthError] = McpOAuthDiscoveryError,
|
|
166
|
+
) -> str:
|
|
167
|
+
if not isinstance(value, str):
|
|
168
|
+
raise error_cls(
|
|
169
|
+
f"field '{field_name}' must be a string.",
|
|
170
|
+
server_id=server_id,
|
|
171
|
+
authorization_server_url=authorization_server_url,
|
|
172
|
+
)
|
|
173
|
+
cleaned = value.strip()
|
|
174
|
+
if not cleaned:
|
|
175
|
+
raise error_cls(
|
|
176
|
+
f"field '{field_name}' cannot be empty.",
|
|
177
|
+
server_id=server_id,
|
|
178
|
+
authorization_server_url=authorization_server_url,
|
|
179
|
+
)
|
|
180
|
+
return cleaned
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _optional_string(
|
|
184
|
+
value: object,
|
|
185
|
+
*,
|
|
186
|
+
field_name: str,
|
|
187
|
+
server_id: str,
|
|
188
|
+
authorization_server_url: str | None = None,
|
|
189
|
+
error_cls: type[McpOAuthError] = McpOAuthDiscoveryError,
|
|
190
|
+
) -> str | None:
|
|
191
|
+
if value is None:
|
|
192
|
+
return None
|
|
193
|
+
return _required_string(
|
|
194
|
+
value,
|
|
195
|
+
field_name=field_name,
|
|
196
|
+
server_id=server_id,
|
|
197
|
+
authorization_server_url=authorization_server_url,
|
|
198
|
+
error_cls=error_cls,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _normalize_string_list(
|
|
203
|
+
value: object,
|
|
204
|
+
*,
|
|
205
|
+
field_name: str,
|
|
206
|
+
server_id: str,
|
|
207
|
+
authorization_server_url: str | None = None,
|
|
208
|
+
error_cls: type[McpOAuthError] = McpOAuthDiscoveryError,
|
|
209
|
+
) -> tuple[str, ...]:
|
|
210
|
+
if value is None:
|
|
211
|
+
return ()
|
|
212
|
+
if not isinstance(value, list):
|
|
213
|
+
raise error_cls(
|
|
214
|
+
f"field '{field_name}' must be an array of strings.",
|
|
215
|
+
server_id=server_id,
|
|
216
|
+
authorization_server_url=authorization_server_url,
|
|
217
|
+
)
|
|
218
|
+
normalized: list[str] = []
|
|
219
|
+
for item in value:
|
|
220
|
+
normalized.append(
|
|
221
|
+
_required_string(
|
|
222
|
+
item,
|
|
223
|
+
field_name=field_name,
|
|
224
|
+
server_id=server_id,
|
|
225
|
+
authorization_server_url=authorization_server_url,
|
|
226
|
+
error_cls=error_cls,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return tuple(normalized)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _normalize_scope_list(
|
|
233
|
+
value: object,
|
|
234
|
+
*,
|
|
235
|
+
field_name: str,
|
|
236
|
+
server_id: str,
|
|
237
|
+
authorization_server_url: str | None = None,
|
|
238
|
+
error_cls: type[McpOAuthError] = McpOAuthDiscoveryError,
|
|
239
|
+
) -> tuple[str, ...]:
|
|
240
|
+
return _normalize_requested_scopes(
|
|
241
|
+
_normalize_string_list(
|
|
242
|
+
value,
|
|
243
|
+
field_name=field_name,
|
|
244
|
+
server_id=server_id,
|
|
245
|
+
authorization_server_url=authorization_server_url,
|
|
246
|
+
error_cls=error_cls,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _require_json_object(
|
|
252
|
+
payload: object,
|
|
253
|
+
*,
|
|
254
|
+
context: str,
|
|
255
|
+
server_id: str,
|
|
256
|
+
authorization_server_url: str | None = None,
|
|
257
|
+
) -> dict[str, Any]:
|
|
258
|
+
if not isinstance(payload, dict):
|
|
259
|
+
raise McpOAuthDiscoveryError(
|
|
260
|
+
f"{context} must be a JSON object.",
|
|
261
|
+
server_id=server_id,
|
|
262
|
+
authorization_server_url=authorization_server_url,
|
|
263
|
+
)
|
|
264
|
+
return payload
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _fetch_json(
|
|
268
|
+
*,
|
|
269
|
+
url: str,
|
|
270
|
+
server_id: str,
|
|
271
|
+
timeout_s: float,
|
|
272
|
+
authorization_server_url: str | None = None,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
try:
|
|
275
|
+
response = asyncio.run(
|
|
276
|
+
safe_http_request(
|
|
277
|
+
"GET",
|
|
278
|
+
url,
|
|
279
|
+
timeout=timeout_s,
|
|
280
|
+
headers={"Accept": "application/json"},
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
response.raise_for_status()
|
|
284
|
+
except SafeHttpError as exc:
|
|
285
|
+
raise _McpOAuthDiscoveryLookupError(
|
|
286
|
+
f"request blocked for '{url}': {exc}",
|
|
287
|
+
server_id=server_id,
|
|
288
|
+
authorization_server_url=authorization_server_url or url,
|
|
289
|
+
) from exc
|
|
290
|
+
except httpx.HTTPError as exc:
|
|
291
|
+
raise _McpOAuthDiscoveryLookupError(
|
|
292
|
+
f"request failed for '{url}'.",
|
|
293
|
+
server_id=server_id,
|
|
294
|
+
authorization_server_url=authorization_server_url or url,
|
|
295
|
+
) from exc
|
|
296
|
+
try:
|
|
297
|
+
payload = response.json()
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
raise McpOAuthDiscoveryError(
|
|
300
|
+
f"response from '{url}' was not valid JSON.",
|
|
301
|
+
server_id=server_id,
|
|
302
|
+
authorization_server_url=authorization_server_url or url,
|
|
303
|
+
) from exc
|
|
304
|
+
return _require_json_object(
|
|
305
|
+
payload,
|
|
306
|
+
context=f"discovery document '{url}'",
|
|
307
|
+
server_id=server_id,
|
|
308
|
+
authorization_server_url=authorization_server_url or url,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def parse_www_authenticate_bearer_challenge(header_value: str | None) -> dict[str, str]:
|
|
313
|
+
text = str(header_value or "").strip()
|
|
314
|
+
if not text:
|
|
315
|
+
return {}
|
|
316
|
+
lowered = text.lower()
|
|
317
|
+
bearer_index = lowered.find("bearer")
|
|
318
|
+
if bearer_index < 0:
|
|
319
|
+
return {}
|
|
320
|
+
bearer_segment = text[bearer_index + len("Bearer") :]
|
|
321
|
+
params: dict[str, str] = {}
|
|
322
|
+
for match in _BEARER_PARAM_RE.finditer(bearer_segment):
|
|
323
|
+
params[match.group(1)] = match.group(2)
|
|
324
|
+
return params
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _normalize_protected_resource_metadata(
|
|
328
|
+
payload: dict[str, Any],
|
|
329
|
+
*,
|
|
330
|
+
server_id: str,
|
|
331
|
+
) -> McpProtectedResourceMetadata:
|
|
332
|
+
resource = _optional_string(payload.get("resource"), field_name="resource", server_id=server_id)
|
|
333
|
+
authorization_servers = _normalize_string_list(
|
|
334
|
+
payload.get("authorization_servers"),
|
|
335
|
+
field_name="authorization_servers",
|
|
336
|
+
server_id=server_id,
|
|
337
|
+
)
|
|
338
|
+
if not authorization_servers:
|
|
339
|
+
raise McpOAuthDiscoveryError(
|
|
340
|
+
"protected resource metadata must include authorization_servers.",
|
|
341
|
+
server_id=server_id,
|
|
342
|
+
)
|
|
343
|
+
normalized_servers = tuple(
|
|
344
|
+
validate_https_url(url, field_name="authorization_servers[]", allow_loopback_http=True)
|
|
345
|
+
for url in authorization_servers
|
|
346
|
+
)
|
|
347
|
+
return McpProtectedResourceMetadata(
|
|
348
|
+
authorization_servers=normalized_servers,
|
|
349
|
+
resource=resource,
|
|
350
|
+
scopes_supported=_normalize_scope_list(
|
|
351
|
+
payload.get("scopes_supported"),
|
|
352
|
+
field_name="scopes_supported",
|
|
353
|
+
server_id=server_id,
|
|
354
|
+
),
|
|
355
|
+
raw_payload=dict(payload),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _normalize_authorization_server_metadata(
|
|
360
|
+
payload: dict[str, Any],
|
|
361
|
+
*,
|
|
362
|
+
server_id: str,
|
|
363
|
+
authorization_server_url: str,
|
|
364
|
+
) -> McpAuthorizationServerMetadata:
|
|
365
|
+
issuer = validate_https_url(
|
|
366
|
+
_required_string(
|
|
367
|
+
payload.get("issuer"),
|
|
368
|
+
field_name="issuer",
|
|
369
|
+
server_id=server_id,
|
|
370
|
+
authorization_server_url=authorization_server_url,
|
|
371
|
+
),
|
|
372
|
+
field_name="issuer",
|
|
373
|
+
allow_loopback_http=True,
|
|
374
|
+
)
|
|
375
|
+
authorization_endpoint = validate_https_url(
|
|
376
|
+
_required_string(
|
|
377
|
+
payload.get("authorization_endpoint"),
|
|
378
|
+
field_name="authorization_endpoint",
|
|
379
|
+
server_id=server_id,
|
|
380
|
+
authorization_server_url=authorization_server_url,
|
|
381
|
+
),
|
|
382
|
+
field_name="authorization_endpoint",
|
|
383
|
+
allow_loopback_http=True,
|
|
384
|
+
)
|
|
385
|
+
token_endpoint = validate_https_url(
|
|
386
|
+
_required_string(
|
|
387
|
+
payload.get("token_endpoint"),
|
|
388
|
+
field_name="token_endpoint",
|
|
389
|
+
server_id=server_id,
|
|
390
|
+
authorization_server_url=authorization_server_url,
|
|
391
|
+
),
|
|
392
|
+
field_name="token_endpoint",
|
|
393
|
+
allow_loopback_http=True,
|
|
394
|
+
)
|
|
395
|
+
return McpAuthorizationServerMetadata(
|
|
396
|
+
issuer=issuer,
|
|
397
|
+
authorization_endpoint=authorization_endpoint,
|
|
398
|
+
token_endpoint=token_endpoint,
|
|
399
|
+
code_challenge_methods_supported=_normalize_string_list(
|
|
400
|
+
payload.get("code_challenge_methods_supported"),
|
|
401
|
+
field_name="code_challenge_methods_supported",
|
|
402
|
+
server_id=server_id,
|
|
403
|
+
authorization_server_url=authorization_server_url,
|
|
404
|
+
),
|
|
405
|
+
response_types_supported=_normalize_string_list(
|
|
406
|
+
payload.get("response_types_supported"),
|
|
407
|
+
field_name="response_types_supported",
|
|
408
|
+
server_id=server_id,
|
|
409
|
+
authorization_server_url=authorization_server_url,
|
|
410
|
+
),
|
|
411
|
+
scopes_supported=_normalize_scope_list(
|
|
412
|
+
payload.get("scopes_supported"),
|
|
413
|
+
field_name="scopes_supported",
|
|
414
|
+
server_id=server_id,
|
|
415
|
+
authorization_server_url=authorization_server_url,
|
|
416
|
+
),
|
|
417
|
+
raw_payload=dict(payload),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _authorization_server_metadata_urls(authorization_server_url: str) -> tuple[str, ...]:
|
|
422
|
+
split = urlsplit(authorization_server_url)
|
|
423
|
+
origin = urlunsplit(SplitResult(split.scheme, split.netloc, "", "", ""))
|
|
424
|
+
if split.path and split.path != "/":
|
|
425
|
+
appended_path = split.path.rstrip("/") + "/.well-known/openid-configuration"
|
|
426
|
+
return (
|
|
427
|
+
f"{origin}/.well-known/oauth-authorization-server{split.path}",
|
|
428
|
+
f"{origin}/.well-known/openid-configuration{split.path}",
|
|
429
|
+
f"{origin}{appended_path}",
|
|
430
|
+
)
|
|
431
|
+
return (
|
|
432
|
+
f"{origin}/.well-known/oauth-authorization-server",
|
|
433
|
+
f"{origin}/.well-known/openid-configuration",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _protected_resource_metadata_urls(resource_server_url: str) -> tuple[str, ...]:
|
|
438
|
+
split = urlsplit(resource_server_url)
|
|
439
|
+
origin = urlunsplit(SplitResult(split.scheme, split.netloc, "", "", ""))
|
|
440
|
+
urls: list[str] = []
|
|
441
|
+
if split.path and split.path != "/":
|
|
442
|
+
urls.append(f"{origin}/.well-known/oauth-protected-resource{split.path}")
|
|
443
|
+
urls.append(f"{origin}/.well-known/oauth-protected-resource")
|
|
444
|
+
return tuple(urls)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def discover_protected_resource_metadata(
|
|
448
|
+
*,
|
|
449
|
+
server_id: str,
|
|
450
|
+
resource_server_url: str,
|
|
451
|
+
unauthorized_response: httpx.Response | None = None,
|
|
452
|
+
timeout_s: float = 10.0,
|
|
453
|
+
client: httpx.Client | None = None,
|
|
454
|
+
) -> McpProtectedResourceMetadata:
|
|
455
|
+
del client
|
|
456
|
+
normalized_resource_url = validate_http_url(
|
|
457
|
+
resource_server_url, field_name="resource_server_url"
|
|
458
|
+
)
|
|
459
|
+
challenge_params = parse_www_authenticate_bearer_challenge(
|
|
460
|
+
unauthorized_response.headers.get("WWW-Authenticate")
|
|
461
|
+
if unauthorized_response is not None
|
|
462
|
+
else None
|
|
463
|
+
)
|
|
464
|
+
resource_metadata_url = challenge_params.get("resource_metadata")
|
|
465
|
+
if resource_metadata_url is not None:
|
|
466
|
+
normalized_metadata_url = validate_https_url(
|
|
467
|
+
resource_metadata_url,
|
|
468
|
+
field_name="resource_metadata",
|
|
469
|
+
allow_loopback_http=True,
|
|
470
|
+
)
|
|
471
|
+
payload = _fetch_json(
|
|
472
|
+
url=normalized_metadata_url,
|
|
473
|
+
server_id=server_id,
|
|
474
|
+
timeout_s=timeout_s,
|
|
475
|
+
)
|
|
476
|
+
return _normalize_protected_resource_metadata(payload, server_id=server_id)
|
|
477
|
+
last_lookup_error: _McpOAuthDiscoveryLookupError | None = None
|
|
478
|
+
for metadata_url in _protected_resource_metadata_urls(normalized_resource_url):
|
|
479
|
+
try:
|
|
480
|
+
payload = _fetch_json(
|
|
481
|
+
url=metadata_url,
|
|
482
|
+
server_id=server_id,
|
|
483
|
+
timeout_s=timeout_s,
|
|
484
|
+
)
|
|
485
|
+
except _McpOAuthDiscoveryLookupError as exc:
|
|
486
|
+
last_lookup_error = exc
|
|
487
|
+
continue
|
|
488
|
+
return _normalize_protected_resource_metadata(payload, server_id=server_id)
|
|
489
|
+
raise McpOAuthDiscoveryError(
|
|
490
|
+
"protected resource metadata discovery failed via path-specific and root well-known fallbacks.",
|
|
491
|
+
server_id=server_id,
|
|
492
|
+
authorization_server_url=normalized_resource_url,
|
|
493
|
+
) from last_lookup_error
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def discover_authorization_server_metadata_from_url(
|
|
497
|
+
*,
|
|
498
|
+
server_id: str,
|
|
499
|
+
authorization_server_url: str,
|
|
500
|
+
timeout_s: float = 10.0,
|
|
501
|
+
client: httpx.Client | None = None,
|
|
502
|
+
) -> McpAuthorizationServerMetadata:
|
|
503
|
+
del client
|
|
504
|
+
normalized_auth_server_url = validate_https_url(
|
|
505
|
+
authorization_server_url,
|
|
506
|
+
field_name="authorization_server_url",
|
|
507
|
+
allow_loopback_http=True,
|
|
508
|
+
)
|
|
509
|
+
last_lookup_error: _McpOAuthDiscoveryLookupError | None = None
|
|
510
|
+
for metadata_url in _authorization_server_metadata_urls(normalized_auth_server_url):
|
|
511
|
+
try:
|
|
512
|
+
payload = _fetch_json(
|
|
513
|
+
url=metadata_url,
|
|
514
|
+
server_id=server_id,
|
|
515
|
+
timeout_s=timeout_s,
|
|
516
|
+
authorization_server_url=normalized_auth_server_url,
|
|
517
|
+
)
|
|
518
|
+
except _McpOAuthDiscoveryLookupError as exc:
|
|
519
|
+
last_lookup_error = exc
|
|
520
|
+
continue
|
|
521
|
+
return _normalize_authorization_server_metadata(
|
|
522
|
+
payload,
|
|
523
|
+
server_id=server_id,
|
|
524
|
+
authorization_server_url=normalized_auth_server_url,
|
|
525
|
+
)
|
|
526
|
+
raise McpOAuthDiscoveryError(
|
|
527
|
+
"authorization server discovery failed via RFC 8414 and OIDC fallbacks.",
|
|
528
|
+
server_id=server_id,
|
|
529
|
+
authorization_server_url=normalized_auth_server_url,
|
|
530
|
+
) from last_lookup_error
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def discover_authorization_server_metadata(
|
|
534
|
+
*,
|
|
535
|
+
server_id: str,
|
|
536
|
+
resource_server_url: str,
|
|
537
|
+
unauthorized_response: httpx.Response | None = None,
|
|
538
|
+
authorization_server_url: str | None = None,
|
|
539
|
+
timeout_s: float = 10.0,
|
|
540
|
+
client: httpx.Client | None = None,
|
|
541
|
+
) -> McpAuthorizationServerMetadata:
|
|
542
|
+
del client
|
|
543
|
+
if authorization_server_url is not None:
|
|
544
|
+
return discover_authorization_server_metadata_from_url(
|
|
545
|
+
server_id=server_id,
|
|
546
|
+
authorization_server_url=authorization_server_url,
|
|
547
|
+
timeout_s=timeout_s,
|
|
548
|
+
)
|
|
549
|
+
protected_metadata = discover_protected_resource_metadata(
|
|
550
|
+
server_id=server_id,
|
|
551
|
+
resource_server_url=resource_server_url,
|
|
552
|
+
unauthorized_response=unauthorized_response,
|
|
553
|
+
timeout_s=timeout_s,
|
|
554
|
+
)
|
|
555
|
+
return discover_authorization_server_metadata_from_url(
|
|
556
|
+
server_id=server_id,
|
|
557
|
+
authorization_server_url=protected_metadata.authorization_servers[0],
|
|
558
|
+
timeout_s=timeout_s,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def generate_pkce_verifier(length: int = 64) -> str:
|
|
563
|
+
if not 43 <= int(length) <= 128:
|
|
564
|
+
raise McpOAuthConfigError(
|
|
565
|
+
"PKCE verifier length must be between 43 and 128 characters.",
|
|
566
|
+
server_id="pkce",
|
|
567
|
+
)
|
|
568
|
+
return "".join(secrets.choice(_PKCE_VERIFIER_CHARS) for _ in range(int(length)))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def build_pkce_challenge(verifier: str) -> str:
|
|
572
|
+
if not isinstance(verifier, str) or not _PKCE_VERIFIER_RE.fullmatch(verifier):
|
|
573
|
+
raise McpOAuthConfigError(
|
|
574
|
+
"PKCE verifier must be 43-128 RFC 3986 unreserved characters.",
|
|
575
|
+
server_id="pkce",
|
|
576
|
+
)
|
|
577
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
578
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def generate_oauth_state() -> str:
|
|
582
|
+
return secrets.token_hex(32)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _normalize_scope_string(scope_value: str) -> tuple[str, ...]:
|
|
586
|
+
parts = [part.strip() for part in scope_value.split(" ")]
|
|
587
|
+
normalized: list[str] = []
|
|
588
|
+
seen: set[str] = set()
|
|
589
|
+
for part in parts:
|
|
590
|
+
if not part or part in seen:
|
|
591
|
+
continue
|
|
592
|
+
seen.add(part)
|
|
593
|
+
normalized.append(part)
|
|
594
|
+
return tuple(normalized)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _normalize_requested_scopes(scopes: tuple[str, ...] | list[str] | None) -> tuple[str, ...]:
|
|
598
|
+
normalized: list[str] = []
|
|
599
|
+
seen: set[str] = set()
|
|
600
|
+
for raw_scope in list(scopes or []):
|
|
601
|
+
cleaned = str(raw_scope or "").strip()
|
|
602
|
+
if not cleaned or cleaned in seen:
|
|
603
|
+
continue
|
|
604
|
+
seen.add(cleaned)
|
|
605
|
+
normalized.append(cleaned)
|
|
606
|
+
return tuple(normalized)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def resolve_requested_scopes(
|
|
610
|
+
*,
|
|
611
|
+
configured_scopes: tuple[str, ...] | list[str] | None,
|
|
612
|
+
challenge_scope: str | None,
|
|
613
|
+
metadata_scopes_supported: tuple[str, ...] | list[str] | None,
|
|
614
|
+
existing_granted_scopes: tuple[str, ...] | list[str] | None,
|
|
615
|
+
purpose: Literal["login", "refresh"],
|
|
616
|
+
) -> tuple[str, ...]:
|
|
617
|
+
configured = _normalize_requested_scopes(configured_scopes)
|
|
618
|
+
if configured:
|
|
619
|
+
return configured
|
|
620
|
+
if purpose == "login":
|
|
621
|
+
challenge = _normalize_scope_string(challenge_scope) if challenge_scope else ()
|
|
622
|
+
if challenge:
|
|
623
|
+
return challenge
|
|
624
|
+
metadata_scopes = _normalize_requested_scopes(metadata_scopes_supported)
|
|
625
|
+
if metadata_scopes:
|
|
626
|
+
return metadata_scopes
|
|
627
|
+
return ()
|
|
628
|
+
if purpose == "refresh":
|
|
629
|
+
granted = _normalize_requested_scopes(existing_granted_scopes)
|
|
630
|
+
if granted:
|
|
631
|
+
return granted
|
|
632
|
+
return ()
|
|
633
|
+
raise ValueError(f"unsupported scope resolution purpose: {purpose}")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _utc_now() -> datetime:
|
|
637
|
+
return datetime.now(UTC).replace(microsecond=0)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _parse_expires_in(
|
|
641
|
+
value: object,
|
|
642
|
+
*,
|
|
643
|
+
server_id: str,
|
|
644
|
+
authorization_server_url: str,
|
|
645
|
+
) -> int:
|
|
646
|
+
if isinstance(value, bool):
|
|
647
|
+
raise McpOAuthTokenExchangeError(
|
|
648
|
+
"token response field 'expires_in' must be a non-negative integer.",
|
|
649
|
+
server_id=server_id,
|
|
650
|
+
authorization_server_url=authorization_server_url,
|
|
651
|
+
)
|
|
652
|
+
if isinstance(value, int):
|
|
653
|
+
expires_in = value
|
|
654
|
+
elif isinstance(value, float) and value.is_integer():
|
|
655
|
+
expires_in = int(value)
|
|
656
|
+
elif isinstance(value, str) and value.strip().isdigit():
|
|
657
|
+
expires_in = int(value.strip())
|
|
658
|
+
else:
|
|
659
|
+
raise McpOAuthTokenExchangeError(
|
|
660
|
+
"token response field 'expires_in' must be a non-negative integer.",
|
|
661
|
+
server_id=server_id,
|
|
662
|
+
authorization_server_url=authorization_server_url,
|
|
663
|
+
)
|
|
664
|
+
if expires_in < 0:
|
|
665
|
+
raise McpOAuthTokenExchangeError(
|
|
666
|
+
"token response field 'expires_in' must be a non-negative integer.",
|
|
667
|
+
server_id=server_id,
|
|
668
|
+
authorization_server_url=authorization_server_url,
|
|
669
|
+
)
|
|
670
|
+
return expires_in
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _normalize_token_response(
|
|
674
|
+
payload: object,
|
|
675
|
+
*,
|
|
676
|
+
server_id: str,
|
|
677
|
+
authorization_server_url: str,
|
|
678
|
+
requested_scopes: tuple[str, ...] | list[str] | None,
|
|
679
|
+
existing_granted_scopes: tuple[str, ...] | list[str] | None = None,
|
|
680
|
+
existing_refresh_token: str | None = None,
|
|
681
|
+
obtained_at: datetime | None = None,
|
|
682
|
+
) -> McpOAuthTokenRecord:
|
|
683
|
+
if not isinstance(payload, dict):
|
|
684
|
+
raise McpOAuthTokenExchangeError(
|
|
685
|
+
"token response must be a JSON object.",
|
|
686
|
+
server_id=server_id,
|
|
687
|
+
authorization_server_url=authorization_server_url,
|
|
688
|
+
)
|
|
689
|
+
access_token = _required_string(
|
|
690
|
+
payload.get("access_token"),
|
|
691
|
+
field_name="access_token",
|
|
692
|
+
server_id=server_id,
|
|
693
|
+
authorization_server_url=authorization_server_url,
|
|
694
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
695
|
+
)
|
|
696
|
+
token_type = _required_string(
|
|
697
|
+
payload.get("token_type"),
|
|
698
|
+
field_name="token_type",
|
|
699
|
+
server_id=server_id,
|
|
700
|
+
authorization_server_url=authorization_server_url,
|
|
701
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
702
|
+
)
|
|
703
|
+
if token_type.casefold() != "bearer":
|
|
704
|
+
raise McpOAuthTokenExchangeError(
|
|
705
|
+
"token response field 'token_type' must be 'Bearer'.",
|
|
706
|
+
server_id=server_id,
|
|
707
|
+
authorization_server_url=authorization_server_url,
|
|
708
|
+
)
|
|
709
|
+
expires_in = _parse_expires_in(
|
|
710
|
+
payload.get("expires_in"),
|
|
711
|
+
server_id=server_id,
|
|
712
|
+
authorization_server_url=authorization_server_url,
|
|
713
|
+
)
|
|
714
|
+
response_scope = _optional_string(
|
|
715
|
+
payload.get("scope"),
|
|
716
|
+
field_name="scope",
|
|
717
|
+
server_id=server_id,
|
|
718
|
+
authorization_server_url=authorization_server_url,
|
|
719
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
720
|
+
)
|
|
721
|
+
refresh_token = _optional_string(
|
|
722
|
+
payload.get("refresh_token"),
|
|
723
|
+
field_name="refresh_token",
|
|
724
|
+
server_id=server_id,
|
|
725
|
+
authorization_server_url=authorization_server_url,
|
|
726
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
727
|
+
)
|
|
728
|
+
if refresh_token is None:
|
|
729
|
+
refresh_token = existing_refresh_token
|
|
730
|
+
normalized_requested_scopes = _normalize_requested_scopes(requested_scopes)
|
|
731
|
+
normalized_existing_granted_scopes = _normalize_requested_scopes(existing_granted_scopes)
|
|
732
|
+
if response_scope is not None:
|
|
733
|
+
granted_scopes = _normalize_scope_string(response_scope)
|
|
734
|
+
elif normalized_requested_scopes:
|
|
735
|
+
granted_scopes = normalized_requested_scopes
|
|
736
|
+
elif normalized_existing_granted_scopes:
|
|
737
|
+
granted_scopes = normalized_existing_granted_scopes
|
|
738
|
+
else:
|
|
739
|
+
granted_scopes = ()
|
|
740
|
+
recorded_at = (obtained_at or _utc_now()).astimezone(UTC).replace(microsecond=0)
|
|
741
|
+
return McpOAuthTokenRecord(
|
|
742
|
+
access_token=access_token,
|
|
743
|
+
token_type=token_type,
|
|
744
|
+
expires_at=recorded_at + timedelta(seconds=expires_in),
|
|
745
|
+
refresh_token=refresh_token,
|
|
746
|
+
granted_scopes=granted_scopes,
|
|
747
|
+
obtained_at=recorded_at,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def is_token_expired(record: McpOAuthTokenRecord, *, now: datetime | None = None) -> bool:
|
|
752
|
+
reference = (now or _utc_now()).astimezone(UTC).replace(microsecond=0)
|
|
753
|
+
return reference >= record.expires_at
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def build_authorization_url(
|
|
757
|
+
*,
|
|
758
|
+
server_id: str,
|
|
759
|
+
authorization_server_metadata: McpAuthorizationServerMetadata,
|
|
760
|
+
resource_server_url: str,
|
|
761
|
+
client_id: str,
|
|
762
|
+
redirect_uri: str,
|
|
763
|
+
code_challenge: str,
|
|
764
|
+
state: str,
|
|
765
|
+
scopes: tuple[str, ...] | list[str] | None = None,
|
|
766
|
+
) -> str:
|
|
767
|
+
normalized_redirect_uri = validate_http_url(redirect_uri, field_name="redirect_uri")
|
|
768
|
+
normalized_client_id = _required_string(
|
|
769
|
+
client_id,
|
|
770
|
+
field_name="client_id",
|
|
771
|
+
server_id=server_id,
|
|
772
|
+
authorization_server_url=authorization_server_metadata.authorization_endpoint,
|
|
773
|
+
error_cls=McpOAuthConfigError,
|
|
774
|
+
)
|
|
775
|
+
normalized_state = _required_string(
|
|
776
|
+
state,
|
|
777
|
+
field_name="state",
|
|
778
|
+
server_id=server_id,
|
|
779
|
+
authorization_server_url=authorization_server_metadata.authorization_endpoint,
|
|
780
|
+
error_cls=McpOAuthConfigError,
|
|
781
|
+
)
|
|
782
|
+
normalized_challenge = _required_string(
|
|
783
|
+
code_challenge,
|
|
784
|
+
field_name="code_challenge",
|
|
785
|
+
server_id=server_id,
|
|
786
|
+
authorization_server_url=authorization_server_metadata.authorization_endpoint,
|
|
787
|
+
error_cls=McpOAuthConfigError,
|
|
788
|
+
)
|
|
789
|
+
params = {
|
|
790
|
+
"response_type": "code",
|
|
791
|
+
"client_id": normalized_client_id,
|
|
792
|
+
"redirect_uri": normalized_redirect_uri,
|
|
793
|
+
"resource": canonical_mcp_resource_uri(resource_server_url),
|
|
794
|
+
"code_challenge": normalized_challenge,
|
|
795
|
+
"code_challenge_method": "S256",
|
|
796
|
+
"state": normalized_state,
|
|
797
|
+
}
|
|
798
|
+
normalized_scopes = _normalize_requested_scopes(scopes)
|
|
799
|
+
if normalized_scopes:
|
|
800
|
+
params["scope"] = " ".join(normalized_scopes)
|
|
801
|
+
return f"{authorization_server_metadata.authorization_endpoint}?{urlencode(params)}"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _post_token_request(
|
|
805
|
+
*,
|
|
806
|
+
server_id: str,
|
|
807
|
+
token_endpoint: str,
|
|
808
|
+
payload: dict[str, str],
|
|
809
|
+
timeout_s: float,
|
|
810
|
+
client: httpx.Client | None = None,
|
|
811
|
+
) -> dict[str, Any]:
|
|
812
|
+
del client
|
|
813
|
+
encoded_payload = urlencode(payload).encode("utf-8")
|
|
814
|
+
try:
|
|
815
|
+
response = asyncio.run(
|
|
816
|
+
safe_http_request(
|
|
817
|
+
"POST",
|
|
818
|
+
token_endpoint,
|
|
819
|
+
timeout=timeout_s,
|
|
820
|
+
headers={
|
|
821
|
+
"Accept": "application/json",
|
|
822
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
823
|
+
},
|
|
824
|
+
content=encoded_payload,
|
|
825
|
+
)
|
|
826
|
+
)
|
|
827
|
+
except httpx.TimeoutException as exc:
|
|
828
|
+
raise McpOAuthTokenExchangeError(
|
|
829
|
+
"token endpoint request timed out.",
|
|
830
|
+
server_id=server_id,
|
|
831
|
+
authorization_server_url=token_endpoint,
|
|
832
|
+
) from exc
|
|
833
|
+
except SafeHttpError as exc:
|
|
834
|
+
raise McpOAuthTokenExchangeError(
|
|
835
|
+
f"token endpoint request blocked: {exc}",
|
|
836
|
+
server_id=server_id,
|
|
837
|
+
authorization_server_url=token_endpoint,
|
|
838
|
+
) from exc
|
|
839
|
+
except httpx.HTTPError as exc:
|
|
840
|
+
raise McpOAuthTokenExchangeError(
|
|
841
|
+
"token endpoint request failed.",
|
|
842
|
+
server_id=server_id,
|
|
843
|
+
authorization_server_url=token_endpoint,
|
|
844
|
+
) from exc
|
|
845
|
+
if response.status_code >= 400:
|
|
846
|
+
raise McpOAuthTokenExchangeError(
|
|
847
|
+
f"token endpoint returned status {response.status_code}.",
|
|
848
|
+
server_id=server_id,
|
|
849
|
+
authorization_server_url=token_endpoint,
|
|
850
|
+
)
|
|
851
|
+
try:
|
|
852
|
+
return _require_json_object(
|
|
853
|
+
response.json(),
|
|
854
|
+
context="token response",
|
|
855
|
+
server_id=server_id,
|
|
856
|
+
authorization_server_url=token_endpoint,
|
|
857
|
+
)
|
|
858
|
+
except McpOAuthDiscoveryError as exc:
|
|
859
|
+
raise McpOAuthTokenExchangeError(
|
|
860
|
+
"token response must be a JSON object.",
|
|
861
|
+
server_id=server_id,
|
|
862
|
+
authorization_server_url=token_endpoint,
|
|
863
|
+
) from exc
|
|
864
|
+
except ValueError as exc:
|
|
865
|
+
raise McpOAuthTokenExchangeError(
|
|
866
|
+
"token response was not valid JSON.",
|
|
867
|
+
server_id=server_id,
|
|
868
|
+
authorization_server_url=token_endpoint,
|
|
869
|
+
) from exc
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def exchange_authorization_code(
|
|
873
|
+
*,
|
|
874
|
+
server_id: str,
|
|
875
|
+
authorization_server_metadata: McpAuthorizationServerMetadata,
|
|
876
|
+
resource_server_url: str,
|
|
877
|
+
client_id: str,
|
|
878
|
+
code: str,
|
|
879
|
+
redirect_uri: str,
|
|
880
|
+
code_verifier: str,
|
|
881
|
+
requested_scopes: tuple[str, ...] | list[str] | None = None,
|
|
882
|
+
timeout_s: float = 10.0,
|
|
883
|
+
client: httpx.Client | None = None,
|
|
884
|
+
) -> McpOAuthTokenRecord:
|
|
885
|
+
normalized_code = _required_string(
|
|
886
|
+
code,
|
|
887
|
+
field_name="code",
|
|
888
|
+
server_id=server_id,
|
|
889
|
+
authorization_server_url=authorization_server_metadata.token_endpoint,
|
|
890
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
891
|
+
)
|
|
892
|
+
normalized_client_id = _required_string(
|
|
893
|
+
client_id,
|
|
894
|
+
field_name="client_id",
|
|
895
|
+
server_id=server_id,
|
|
896
|
+
authorization_server_url=authorization_server_metadata.token_endpoint,
|
|
897
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
898
|
+
)
|
|
899
|
+
normalized_redirect_uri = validate_http_url(redirect_uri, field_name="redirect_uri")
|
|
900
|
+
build_pkce_challenge(code_verifier)
|
|
901
|
+
token_payload = _post_token_request(
|
|
902
|
+
server_id=server_id,
|
|
903
|
+
token_endpoint=authorization_server_metadata.token_endpoint,
|
|
904
|
+
payload={
|
|
905
|
+
"grant_type": "authorization_code",
|
|
906
|
+
"code": normalized_code,
|
|
907
|
+
"redirect_uri": normalized_redirect_uri,
|
|
908
|
+
"client_id": normalized_client_id,
|
|
909
|
+
"resource": canonical_mcp_resource_uri(resource_server_url),
|
|
910
|
+
"code_verifier": code_verifier,
|
|
911
|
+
},
|
|
912
|
+
timeout_s=timeout_s,
|
|
913
|
+
client=client,
|
|
914
|
+
)
|
|
915
|
+
return _normalize_token_response(
|
|
916
|
+
token_payload,
|
|
917
|
+
server_id=server_id,
|
|
918
|
+
authorization_server_url=authorization_server_metadata.token_endpoint,
|
|
919
|
+
requested_scopes=requested_scopes,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def refresh_access_token(
|
|
924
|
+
*,
|
|
925
|
+
server_id: str,
|
|
926
|
+
authorization_server_metadata: McpAuthorizationServerMetadata,
|
|
927
|
+
resource_server_url: str,
|
|
928
|
+
client_id: str,
|
|
929
|
+
refresh_token: str,
|
|
930
|
+
requested_scopes: tuple[str, ...] | list[str] | None = None,
|
|
931
|
+
existing_granted_scopes: tuple[str, ...] | list[str] | None = None,
|
|
932
|
+
timeout_s: float = 10.0,
|
|
933
|
+
client: httpx.Client | None = None,
|
|
934
|
+
) -> McpOAuthTokenRecord:
|
|
935
|
+
normalized_refresh_token = _required_string(
|
|
936
|
+
refresh_token,
|
|
937
|
+
field_name="refresh_token",
|
|
938
|
+
server_id=server_id,
|
|
939
|
+
authorization_server_url=authorization_server_metadata.token_endpoint,
|
|
940
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
941
|
+
)
|
|
942
|
+
normalized_client_id = _required_string(
|
|
943
|
+
client_id,
|
|
944
|
+
field_name="client_id",
|
|
945
|
+
server_id=server_id,
|
|
946
|
+
authorization_server_url=authorization_server_metadata.token_endpoint,
|
|
947
|
+
error_cls=McpOAuthTokenExchangeError,
|
|
948
|
+
)
|
|
949
|
+
normalized_requested_scopes = _normalize_requested_scopes(requested_scopes)
|
|
950
|
+
token_request_payload = {
|
|
951
|
+
"grant_type": "refresh_token",
|
|
952
|
+
"refresh_token": normalized_refresh_token,
|
|
953
|
+
"client_id": normalized_client_id,
|
|
954
|
+
"resource": canonical_mcp_resource_uri(resource_server_url),
|
|
955
|
+
}
|
|
956
|
+
if normalized_requested_scopes:
|
|
957
|
+
token_request_payload["scope"] = " ".join(normalized_requested_scopes)
|
|
958
|
+
token_payload = _post_token_request(
|
|
959
|
+
server_id=server_id,
|
|
960
|
+
token_endpoint=authorization_server_metadata.token_endpoint,
|
|
961
|
+
payload=token_request_payload,
|
|
962
|
+
timeout_s=timeout_s,
|
|
963
|
+
client=client,
|
|
964
|
+
)
|
|
965
|
+
return _normalize_token_response(
|
|
966
|
+
token_payload,
|
|
967
|
+
server_id=server_id,
|
|
968
|
+
authorization_server_url=authorization_server_metadata.token_endpoint,
|
|
969
|
+
requested_scopes=requested_scopes,
|
|
970
|
+
existing_granted_scopes=existing_granted_scopes,
|
|
971
|
+
existing_refresh_token=normalized_refresh_token,
|
|
972
|
+
)
|