pmcp 2.7.2__tar.gz → 2.7.3__tar.gz
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.
- pmcp-2.7.3/.consiliency/plans/detailed-auth-trust-split-20260830-1600.md +199 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/CHANGELOG.md +32 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/PKG-INFO +8 -2
- {pmcp-2.7.2 → pmcp-2.7.3}/README.md +7 -1
- {pmcp-2.7.2 → pmcp-2.7.3}/SECURITY.md +11 -2
- {pmcp-2.7.2 → pmcp-2.7.3}/pyproject.toml +1 -1
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/__init__.py +1 -1
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/auth.py +140 -12
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli.py +27 -2
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/doctor.py +12 -1
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/tools/handlers.py +15 -2
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/types.py +40 -3
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_auth.py +270 -6
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_cli.py +200 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_ssrf_registry.py +5 -1
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_tools.py +160 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/uv.lock +1 -1
- {pmcp-2.7.2 → pmcp-2.7.3}/.claude/docs-catalog.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.claude/plans/ticklish-inventing-stearns.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/bypass-proofs-187.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-175.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-180.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-183.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-189.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/derive_npm_flags.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/differential_npm_corpus.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/mutate.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/sse-stream-ended-investigation-20260812.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/verify_tables.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-anyio-transport-task-ownership-20260811-0204.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-catalog-indexing-hardening-20260827-1745.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-ci-job-timeouts-20260826-0400.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-ci-workflow-guards-20260826-2205.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-installed-version-from-runner-cache-20260817-1600.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-installed-version-from-serverinfo-20260817-0400.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-limit-bound-20260829-1500.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-nopt-backed-npm-identity-20260826-2150.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-npm-flag-classification-20260826-1130.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-package-identity-parsing-20260825-0900.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-policy-fail-open-20260829-1200.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-public-host-check-20260830-1200.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-remove-automatic-update-notices-20260819-0100.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-spawn-time-version-provenance-20260817-1800.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-stale-notice-package-identity-20260817-0300.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-update-server-resolution-residuals-20260816-1824.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-update-server-toctou-20260820-1300.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-value-flag-collisions-20260826-0500.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-verify-host-enumerated-types-20260827-1730.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-version-probe-ask-the-server-20260817-2000.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.dockerignore +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.env.example +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/CODEOWNERS +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/actions/pipeline-bootstrap-setup/action.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/dependabot.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/probe/p1_probe_client.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/probe/p1_probe_server.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/docker.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/maintenance.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/pipeline-bootstrap.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/release.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/test.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.gitignore +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.mcp-gateway/descriptions.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.mcp.json.example +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/fast-build-spark.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/impl-codex53.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/planner-gemini.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/qa-debug.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/research-spark.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/.pr_details.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/CONTRIBUTING.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/Dockerfile +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/LICENSE +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/SPEC_COMPLIANCE.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/agent-manifest.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/catalog-info.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/config/.mcp.json.example +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/.gitignore +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/RUNBOOK.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/repro_client.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/run.sh +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/slow_server.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/synthetic.mcp.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/docker-compose.yml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/examples/gateway-only.mcp.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/examples/gateway-policy.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/examples/sample-backends.mcp.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/examples/scoped-advisor-policy.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/generated/.gitkeep +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/HANDOFF-agent-board-mcp-registration.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/ROADMAP-pmcp-agent-board-mcp.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/agent-board-overlay.template.jsonc +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/codebase-review-2026-06-15.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-103-scoped-advisor-audit-20260726.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-describe-nested-schema-and-indexit-stdio-20260706-0920.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-idle-timeout-downstream-calls-20260628-0646.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-mcp-2x-spec-2026-07-28-stage1-20260805-1740.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-overlay-server-env-patch-20260801-1530.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-private-manifest-overlay-20260629-0841.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/manifest.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-config.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-observe.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-resolver.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-runtime.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P1.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P2.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P3B.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P5.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P6CLEAN.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-PG.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v12-FANOUT.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v12-TRISTATE.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v12-UPDPATH.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-lifecycle.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-ops.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-refresh.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-serialize.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-soak.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-auth.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-config.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-conform.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-observe.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-proto.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-tasks.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-authobs.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-authsoak.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-elicit.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-remote.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-safeurl.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-store.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-catalogcli.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-clihint.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-climatch.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-clisoak.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-reqcli.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostcontract.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostmeta.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostpolicy.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostreg.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostsoak.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-AUTHRS.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-CONCURR.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-ENVFIX.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-MANIFEST.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-REDACT.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-REGISTRY.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-AUTHFIX.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-COMPLETE.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-MATCHFIX.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-REGFIX.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-RELEASE.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v9-SPECCURRENCY.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/v7-code-review-findings.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/plans/v7-issue-tracker.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/_mutate_workflow.sh +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/check_workflows.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/emit-event.mjs +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/package-lock.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/package.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/run.mjs +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/README.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/claude +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/codex +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/gemini +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/_serverkill_runner.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/run_sse_flake_probe.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/run_sse_flake_probe_interrupt.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_interrupt.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_serverkill.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_sigkill.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_sigkill_connect.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/server.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/README.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/active/pmcp-gateway-orchestration-plan.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v1.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v10.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v11.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v12.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v2.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v3.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v4.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v5.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v6.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v7.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v8.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v9.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/specs/tenant-code-mode-host-contract.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/__main__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/install.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/secrets.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/client/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/client/manager.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/config/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/config/guidance.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/config/loader.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/env_store.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/errors.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/identity.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/_npm_resolve.js +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/code_patterns.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/code_patterns_loader.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/environment.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/installer.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/loader.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/manifest.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/matcher.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/npm_resolver.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/refresher.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/registry.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/sync.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/version_checker.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/policy/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/policy/policy.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/py.typed +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/remote_auth.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/resources/code_execution_guide.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/scoped_advisor_audit.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/server.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/subscriptions.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/summary/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/summary/generator.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/summary/template_fallback.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/templates/code_examples.yaml +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/templates/code_snippets_loader.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/tools/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/transport/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/transport/http.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/validation.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/team_announcement.md +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/conftest.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/fixtures/npm/schema.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/fixtures/registry/v0_servers_page1.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/fixtures/registry/v0_servers_page2.json +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_catalog_publishers.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_client_transport.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_dependency_bounds.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_get_retirement.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_listen_over_http.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_listen_registration.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_server_handlers.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_subscription_contract.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/__init__.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/conftest.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/fake_remote.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/fake_stdio_server.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/harness.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_downstream_handshake_era.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_downstream_remote.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_downstream_stdio.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_emitter_harness.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_get_retired.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_harness.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_publisher_coverage.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_subscriptions_e2e.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_wire_handshake_era.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_wire_modern_era.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_auth_origin_wiring.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_baseline_constraints.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_cli_p4.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_client_manager.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_client_manager_reconnect.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_config_loader.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_boot.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_child_env.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_gates_handlers.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_gates_startup.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_optionality_e2e.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_predicate_guard.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_requirement.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_env_store.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_errors.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_guidance_config.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_http_dos.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_http_transport.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_identity.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_install_command.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_integration.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_lazy_start.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_manifest.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_manifest_overlay.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_manifest_provision.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_npm_resolver.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_offline_discovery.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_package_metadata.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_phase4_e2e.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_phase6_tenant_code_mode.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_policy.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_policy_fail_open.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_progressive_disclosure.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_provision_validation.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_refresher.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_registry.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_scoped_advisor_audit.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_secrets_command.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_server.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_server_lifecycle.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_setup_command.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_singleton_lock_scope.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_startup_resolver.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_summary.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_transport_http.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_version_checker.py +0 -0
- {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_workflow_guards.py +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Detailed plan: stop vouching for URLs a downstream server supplied
|
|
2
|
+
|
|
3
|
+
> **Revision 3 (2026-08-30).** Rev 2 boarded 1 DISAGREE / 1 PARTIALLY AGREE /
|
|
4
|
+
> 1 AGREE. Its `fetch_json_metadata` criterion was **already satisfied by
|
|
5
|
+
> unchanged `main`**, it proposed deleting a **frozen interface**, and it left
|
|
6
|
+
> the string agents actually follow unqualified. All three verified.
|
|
7
|
+
>
|
|
8
|
+
> **Revision 2 (2026-08-30).** Boarded 2 DISAGREE. Rev 1 named the wrong types
|
|
9
|
+
> and would have broken a frozen operator contract. Both verified below.
|
|
10
|
+
|
|
11
|
+
## Task
|
|
12
|
+
|
|
13
|
+
Close Consiliency/pmcp#211. `sanitize_public_auth_url` applies one permissiveness
|
|
14
|
+
to inputs of very different provenance, and pmcp presents the result as
|
|
15
|
+
validated. A downstream server can hand back a URL on an internal host and have
|
|
16
|
+
pmcp show it to the operator as though pmcp checked it.
|
|
17
|
+
|
|
18
|
+
## What #210 already closed — measured today, post-merge
|
|
19
|
+
|
|
20
|
+
#211 was filed before #210 landed. Re-checking the untrusted elicitation path
|
|
21
|
+
now:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
https://0xA9FEA9FE/x rejected <- #210 closed this
|
|
25
|
+
https://metadata.google.internal/x ACCEPTED <- remains
|
|
26
|
+
http://127.0.0.1/cb ACCEPTED <- remains
|
|
27
|
+
https://auth.vendor.com/x ACCEPTED <- legitimate, must stay
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
So the numeric-form relay is gone. **What is left is names, and loopback HTTP.**
|
|
31
|
+
That is a materially smaller issue than filed, and the plan is scoped to it.
|
|
32
|
+
|
|
33
|
+
Two other open questions from the issue, now answered:
|
|
34
|
+
|
|
35
|
+
- **The JWKS URL is purely operator-supplied.** It reaches
|
|
36
|
+
`transport/http.py:281` only from `cli.py:2295`'s `--oauth-jwks-url`. It does
|
|
37
|
+
**not** arrive from a manifest or registry entry. The issue speculated it
|
|
38
|
+
might; it does not.
|
|
39
|
+
- **`fetch_json_metadata` still has no production caller** — only its own
|
|
40
|
+
definition at `auth.py:756`.
|
|
41
|
+
|
|
42
|
+
## The shape of the fix
|
|
43
|
+
|
|
44
|
+
**Do not reject names on the untrusted paths.** A board round established that
|
|
45
|
+
rejecting unverifiable names there would refuse
|
|
46
|
+
`https://auth.vendor.com/...` from a well-behaved server — killing the feature
|
|
47
|
+
the check exists to protect. Rejecting is right for a *fetch* and wrong for a
|
|
48
|
+
*relay*.
|
|
49
|
+
|
|
50
|
+
The harm is not that pmcp accepts the URL. It is that pmcp **presents it as
|
|
51
|
+
validated**. So:
|
|
52
|
+
|
|
53
|
+
- **Fail closed on fetches.** Anything pmcp will retrieve must be verifiably
|
|
54
|
+
public.
|
|
55
|
+
- **Stop vouching on relays.** Keep passing the URL through, but stop implying
|
|
56
|
+
pmcp checked where it points.
|
|
57
|
+
|
|
58
|
+
### `src/pmcp/auth.py` (modify)
|
|
59
|
+
|
|
60
|
+
- `fetch_json_metadata` (`:756`) — **gate it. Do not delete it.**
|
|
61
|
+
**WAS WRONG (rev 2):** it said deleting is preferred. Verified — this function
|
|
62
|
+
is a **frozen interface**: `plans/phase-plan-v4-safeurl.md:48` lists it under
|
|
63
|
+
*Interfaces provided*, and **IF-0-SAFEURL-4** names it by contract. Deleting it
|
|
64
|
+
breaks that freeze; gating preserves compatibility.
|
|
65
|
+
|
|
66
|
+
It fetches, so it is the fail-closed side: it must reject anything that is not
|
|
67
|
+
a **verifiably public literal** — names included, and loopback HTTP included.
|
|
68
|
+
Verified on `main` today: `https://10.0.0.1/x` is already rejected, but
|
|
69
|
+
`http://127.0.0.1/x` and `https://metadata.google.internal/x` both **reach
|
|
70
|
+
`urlopen`**.
|
|
71
|
+
- `sanitize_url_elicitation_url` (`:485`) — modify — **split it by provenance.**
|
|
72
|
+
**WAS WRONG (rev 1):** it said "stop passing `allow_loopback_http=True`",
|
|
73
|
+
full stop. Verified: this helper is **shared** between the downstream-payload
|
|
74
|
+
path (`auth.py:736`) and the **operator-supplied** acknowledgement path
|
|
75
|
+
(`handlers.py:4592`, `parsed.elicitation_url`). Removing loopback globally
|
|
76
|
+
would break the operator's local-OAuth flow, which `plans/phase-plan-v4-elicit.md`
|
|
77
|
+
records as a frozen contract. The remote path must lose loopback HTTP; the
|
|
78
|
+
operator path must keep it. Two policies, two call sites, regression tests for
|
|
79
|
+
both.
|
|
80
|
+
- A `verified` signal on the returned value — add — whether the host was
|
|
81
|
+
verified as a public literal or merely accepted as an unresolved name. It must
|
|
82
|
+
**default to unverified**: a default of "verified" reintroduces vouching at any
|
|
83
|
+
call site the change misses.
|
|
84
|
+
|
|
85
|
+
### Presentation (modify)
|
|
86
|
+
|
|
87
|
+
- **`UrlElicitationInfo` (`types.py:163`) is the primary relay surface and rev 1
|
|
88
|
+
omitted it entirely.** Its docstring reads *"URL-mode elicitation details **safe
|
|
89
|
+
to display to users**"* — the type asserts precisely the claim this issue
|
|
90
|
+
disproves. It carries `url`, `message`, `next_step` and no verification field,
|
|
91
|
+
and `handlers.py:4610` constructs it with an unconditional open-the-URL
|
|
92
|
+
instruction. Add the signal here first; without it AC 3 cannot be met on the
|
|
93
|
+
surface that actually reaches an operator. Correct the docstring too.
|
|
94
|
+
- `cli.py` — where `url_elicitations` are shown — must state that an unverified
|
|
95
|
+
URL's destination was not checked, in **both** the human text and `--json`.
|
|
96
|
+
Note the copy must not say "came from the server" on the acknowledge path
|
|
97
|
+
(`cli.py:2603-2605`), which echoes an **operator-supplied** URL.
|
|
98
|
+
- `cli_commands/doctor.py:90-95` reports sanitize success as
|
|
99
|
+
`[OK] … configured at {url}` for a name — operator config rather than a
|
|
100
|
+
downstream payload, but another vouch. Qualify it or state why it is exempt.
|
|
101
|
+
- **`AuthMetadataInfo` needs a PER-URL signal, not one object-level bool.** It
|
|
102
|
+
carries five independent URL fields; a single flag cannot be honest when one is
|
|
103
|
+
a public literal and another is a name. Rev 1 specified one signal per object.
|
|
104
|
+
**Keep the URL fields `str | None`** — `transport/http.py:373` interpolates
|
|
105
|
+
`protected_resource_metadata_url` straight into a `WWW-Authenticate` header, so
|
|
106
|
+
wrapping them is a silent contract break. Use companion flags or a side map.
|
|
107
|
+
- **Keep `sanitize_url_elicitation_url` returning `str`** (frozen as
|
|
108
|
+
IF-0-ELICIT-1). Carry `verified` on a small result object or a parallel
|
|
109
|
+
predicate, so every string-equality test does not have to move.
|
|
110
|
+
- `AuthChallengeInfo.resource_metadata_url` — same per-URL treatment.
|
|
111
|
+
|
|
112
|
+
## Documentation impact
|
|
113
|
+
|
|
114
|
+
- `CHANGELOG.md` — `### Changed`: pmcp no longer implies it verified where a
|
|
115
|
+
server-supplied auth URL points, and no longer accepts a loopback HTTP URL from
|
|
116
|
+
a downstream server.
|
|
117
|
+
- `README.md` / `SECURITY.md` — the #210 wording already says the check filters
|
|
118
|
+
IP literals. Extend to state that a server-supplied URL is relayed unverified
|
|
119
|
+
and is presented as such.
|
|
120
|
+
|
|
121
|
+
## Dependencies & order
|
|
122
|
+
|
|
123
|
+
1. The `verified` signal on `UrlElicitationInfo` first, defaulting to
|
|
124
|
+
unverified, with unit tests — it is the surface that reaches an operator.
|
|
125
|
+
2. The provenance split on `sanitize_url_elicitation_url` — remote loses
|
|
126
|
+
loopback HTTP, operator keeps it — with regression tests both ways.
|
|
127
|
+
3. `fetch_json_metadata` — delete or gate.
|
|
128
|
+
4. Presentation changes.
|
|
129
|
+
5. Docs.
|
|
130
|
+
|
|
131
|
+
## Verification
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uv run pytest -q tests/test_auth.py
|
|
135
|
+
uv run pytest -q
|
|
136
|
+
uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run mypy src/
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`/tmp/package.json` and `/tmp/node_modules` on this host make ~107 npm-identity
|
|
140
|
+
tests fail locally in a way that reproduces on a clean `main` export
|
|
141
|
+
(`tests/conftest.py`). Verify against `main` before blaming this diff.
|
|
142
|
+
|
|
143
|
+
## Acceptance criteria
|
|
144
|
+
|
|
145
|
+
- [ ] A **downstream** elicitation payload carrying `http://127.0.0.1/cb` is
|
|
146
|
+
**rejected**, proven by a test failing against `main`. **And the operator
|
|
147
|
+
path at `handlers.py:4592` still accepts it** — both directions, or the
|
|
148
|
+
change breaks a frozen contract. `test_sanitize_url_elicitation_url_allows_loopback_http`
|
|
149
|
+
inverts for the remote path only.
|
|
150
|
+
- [ ] `https://auth.vendor.com/...` from a downstream server is **still
|
|
151
|
+
accepted** — the feature must survive. A change that rejects names must not
|
|
152
|
+
pass.
|
|
153
|
+
- [ ] A relayed URL whose host is an unresolved name is marked **unverified** on
|
|
154
|
+
`UrlElicitationInfo`, and the gateway output, the CLI human text **and**
|
|
155
|
+
`--json` all say so. Asserted on emitted text, not on a field's presence.
|
|
156
|
+
Rev 1's criteria exercised only elicitation and CLI, which would let the
|
|
157
|
+
challenge and metadata surfaces stay misleading while every test passed.
|
|
158
|
+
- [ ] `AuthMetadataInfo` with a **mix** — one public literal, one name — reports
|
|
159
|
+
them differently. A single object-level flag cannot pass this.
|
|
160
|
+
- [ ] **`AuthChallengeInfo.resource_metadata_url`** is covered by the same four
|
|
161
|
+
assertions: unresolved name marked unverified, public literal not marked,
|
|
162
|
+
default unverified, and the serialized output carries it. Rev 2 required
|
|
163
|
+
per-URL treatment for this surface in *Changes* but wrote **no criterion**
|
|
164
|
+
for it — so leaving `parse_www_authenticate` untouched would have satisfied
|
|
165
|
+
every listed criterion while it still relays
|
|
166
|
+
`https://metadata.google.internal/meta` unmarked.
|
|
167
|
+
- [ ] **The `next_step` text is qualified.** The open-the-URL instruction is
|
|
168
|
+
copied to `handlers.py:2133`, `:2786`, `:4215`, `:4349`, `:4436` and printed
|
|
169
|
+
by `cli.py:2519-2521`. That string is what an agent follows. A JSON
|
|
170
|
+
`verified: false` plus a CLI sidebar, with `next_step` still saying "open
|
|
171
|
+
the URL" unqualified, must **not** pass.
|
|
172
|
+
- [ ] The signal defaults to **unverified** — proven by constructing the type
|
|
173
|
+
without it and asserting the unverified presentation.
|
|
174
|
+
- [ ] A relayed URL whose host is a verified public literal is **not** marked
|
|
175
|
+
unverified — so the signal distinguishes, rather than labelling everything.
|
|
176
|
+
- [ ] `fetch_json_metadata` rejects **`http://127.0.0.1/x`** and **an unresolved
|
|
177
|
+
hostname**, each **without invoking the opener** — asserted by patching the
|
|
178
|
+
opener and proving it was never called.
|
|
179
|
+
**WAS WRONG (rev 2):** the criterion was "rejects a non-public literal",
|
|
180
|
+
which unchanged `main` **already satisfies** for `https://10.0.0.1/x`. It
|
|
181
|
+
could be passed by doing nothing, while the two forms that actually reach
|
|
182
|
+
`urlopen` stayed open. This is the fourth vacuous criterion in this
|
|
183
|
+
project; it was caught because a seat ran it rather than read it.
|
|
184
|
+
- [ ] Full suite green.
|
|
185
|
+
|
|
186
|
+
## Non-goals
|
|
187
|
+
|
|
188
|
+
- **Resolving hostnames.** Rejected across three board rounds: TOCTOU, a lookup
|
|
189
|
+
on caller-supplied input, and it is not SSRF defence without connection-time IP
|
|
190
|
+
pinning.
|
|
191
|
+
- **The trailing-dot IPv4 form** (`169.254.169.254.`), which takes the name path.
|
|
192
|
+
It is name-shaped; if the `verified` signal is right, it is relayed unverified
|
|
193
|
+
like any other name.
|
|
194
|
+
- Re-opening #210's literal classification. It is closed and pinned by 22 tests.
|
|
195
|
+
|
|
196
|
+
## Execution Policy
|
|
197
|
+
|
|
198
|
+
- execute: effort=medium, reason=small diff, but it changes what pmcp claims to
|
|
199
|
+
an operator, and the risk is over-rejecting a legitimate auth flow
|
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.7.3] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **PMCP no longer implies it verified where a server-supplied auth URL
|
|
14
|
+
points.** It never did: `_is_public_auth_host` classifies IP literals and
|
|
15
|
+
accepts a DNS name **without resolving it**, so
|
|
16
|
+
`https://metadata.google.internal/...` in an elicitation payload or a
|
|
17
|
+
`WWW-Authenticate` header was relayed to the operator — and to an agent —
|
|
18
|
+
looking like something PMCP had checked. Names are still relayed, because
|
|
19
|
+
refusing unresolvable ones would refuse a well-behaved server's
|
|
20
|
+
`https://auth.vendor.com/...` too; what changed is what PMCP claims about
|
|
21
|
+
them. `UrlElicitationInfo.url_verified`, `AuthMetadataInfo.verified_urls`
|
|
22
|
+
(per URL field, since those five URLs are independent) and
|
|
23
|
+
`AuthChallengeInfo.resource_metadata_url_verified` report whether PMCP itself
|
|
24
|
+
classified the host as a public literal, and all three default to
|
|
25
|
+
**unverified**. The qualification is threaded into the `next_step` string an
|
|
26
|
+
agent actually follows, and into `pmcp auth connect` / `pmcp auth acknowledge`
|
|
27
|
+
output in both human text and `--json`. `pmcp doctor` no longer reports a bare
|
|
28
|
+
`[OK] ... configured at <name>` for a host it did not verify
|
|
29
|
+
([#211](https://github.com/Consiliency/pmcp/issues/211)).
|
|
30
|
+
- **A downstream server can no longer hand back a loopback `http://` URL.**
|
|
31
|
+
`sanitize_url_elicitation_url` now splits by provenance: a URL parsed out of a
|
|
32
|
+
server's error payload loses loopback HTTP, while one the operator types into
|
|
33
|
+
`gateway.auth_connect` keeps it, because local OAuth redirects to
|
|
34
|
+
`http://127.0.0.1`. The default is the strict remote policy, so a call site
|
|
35
|
+
missed by a future change fails closed (#211).
|
|
36
|
+
- **`fetch_json_metadata` now fails closed.** PMCP retrieves that URL itself, so
|
|
37
|
+
"accepted but unresolved" is not good enough there: the host must be one PMCP
|
|
38
|
+
verified as a public IP literal, and a refused URL is rejected before the
|
|
39
|
+
opener is reached rather than fetched and judged afterwards. `http://127.0.0.1/x`
|
|
40
|
+
and unresolved names previously both reached `urlopen` (#211).
|
|
41
|
+
|
|
10
42
|
## [2.7.2] - 2026-08-30
|
|
11
43
|
|
|
12
44
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pmcp
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.3
|
|
4
4
|
Summary: PMCP - Progressive MCP: Minimal context bloat with on-demand tool discovery
|
|
5
5
|
Project-URL: Repository, https://github.com/Consiliency/pmcp
|
|
6
6
|
Project-URL: Homepage, https://github.com/Consiliency/pmcp
|
|
@@ -240,7 +240,13 @@ literals** — private, CGNAT, link-local, loopback, multicast, site-local, and
|
|
|
240
240
|
unspecified — including IPv4 addresses embedded in IPv6 literals and legacy
|
|
241
241
|
numeric forms such as `2852039166`. This is a filter on literals only: a DNS
|
|
242
242
|
name is accepted **without being resolved**, so a name that points at an
|
|
243
|
-
internal address still passes
|
|
243
|
+
internal address still passes. PMCP therefore no longer presents such a URL as
|
|
244
|
+
one it checked: a server-supplied URL is **relayed unverified and presented as
|
|
245
|
+
such** — `UrlElicitationInfo.url_verified`, `AuthMetadataInfo.verified_urls`,
|
|
246
|
+
and `AuthChallengeInfo.resource_metadata_url_verified` all default to
|
|
247
|
+
unverified, and the caveat is carried in the `next_step` an agent follows and in
|
|
248
|
+
CLI output. Where PMCP *fetches* a URL itself it fails closed instead, requiring
|
|
249
|
+
a verified public literal
|
|
244
250
|
([#211](https://github.com/Consiliency/pmcp/issues/211)). PMCP is still not an
|
|
245
251
|
Authorization Server and does not provide dynamic client registration, SSO,
|
|
246
252
|
RBAC, billing, or a complete multi-tenant identity service.
|
|
@@ -176,7 +176,13 @@ literals** — private, CGNAT, link-local, loopback, multicast, site-local, and
|
|
|
176
176
|
unspecified — including IPv4 addresses embedded in IPv6 literals and legacy
|
|
177
177
|
numeric forms such as `2852039166`. This is a filter on literals only: a DNS
|
|
178
178
|
name is accepted **without being resolved**, so a name that points at an
|
|
179
|
-
internal address still passes
|
|
179
|
+
internal address still passes. PMCP therefore no longer presents such a URL as
|
|
180
|
+
one it checked: a server-supplied URL is **relayed unverified and presented as
|
|
181
|
+
such** — `UrlElicitationInfo.url_verified`, `AuthMetadataInfo.verified_urls`,
|
|
182
|
+
and `AuthChallengeInfo.resource_metadata_url_verified` all default to
|
|
183
|
+
unverified, and the caveat is carried in the `next_step` an agent follows and in
|
|
184
|
+
CLI output. Where PMCP *fetches* a URL itself it fails closed instead, requiring
|
|
185
|
+
a verified public literal
|
|
180
186
|
([#211](https://github.com/Consiliency/pmcp/issues/211)). PMCP is still not an
|
|
181
187
|
Authorization Server and does not provide dynamic client registration, SSO,
|
|
182
188
|
RBAC, billing, or a complete multi-tenant identity service.
|
|
@@ -72,8 +72,17 @@ PMCP is a local-first MCP gateway. Its default security posture assumes:
|
|
|
72
72
|
RFC 6052 NAT64, RFC 3056 6to4, RFC 4380 Teredo, RFC 5214 ISATAP) and legacy
|
|
73
73
|
numeric forms such as `2852039166` or `0177.0.0.1`. **A DNS name is accepted
|
|
74
74
|
without being resolved**, so a name pointing at an internal address is not
|
|
75
|
-
caught.
|
|
76
|
-
|
|
75
|
+
caught. PMCP does not resolve names deliberately: a lookup is
|
|
76
|
+
TOCTOU-vulnerable and is not SSRF defence without connection-time IP pinning.
|
|
77
|
+
What it does instead is stop claiming otherwise — **a server-supplied URL is
|
|
78
|
+
relayed unverified and presented as such**, via
|
|
79
|
+
`UrlElicitationInfo.url_verified`, `AuthMetadataInfo.verified_urls` (one entry
|
|
80
|
+
per URL field), and `AuthChallengeInfo.resource_metadata_url_verified`, all
|
|
81
|
+
defaulting to unverified, with the caveat carried in the `next_step` string an
|
|
82
|
+
agent follows and in `pmcp auth` output. Paths where PMCP fetches the URL
|
|
83
|
+
itself fail closed and require a verified public literal, and a URL from a
|
|
84
|
+
downstream server's payload may not be loopback `http://`
|
|
85
|
+
([#211](https://github.com/Consiliency/pmcp/issues/211)).
|
|
77
86
|
- **No mTLS**: clients are not authenticated by certificate; only Bearer token.
|
|
78
87
|
- **No per-tool ACL on HTTP**: any valid token can invoke any tool. Tool-level policy is
|
|
79
88
|
enforced at the MCP layer, not the HTTP layer.
|
|
@@ -10,7 +10,7 @@ from collections.abc import Mapping
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
|
|
12
12
|
from itertools import product
|
|
13
|
-
from typing import Any
|
|
13
|
+
from typing import Any, Literal
|
|
14
14
|
from urllib.error import HTTPError
|
|
15
15
|
from urllib.parse import parse_qsl, quote, urlparse, urlunparse
|
|
16
16
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
@@ -260,6 +260,66 @@ def _is_public_auth_host(hostname: str) -> bool:
|
|
|
260
260
|
return True
|
|
261
261
|
|
|
262
262
|
|
|
263
|
+
def _is_verified_public_auth_host(hostname: str) -> bool:
|
|
264
|
+
"""Report whether PMCP itself classified this host as a public literal.
|
|
265
|
+
|
|
266
|
+
This is the honest half of :func:`_is_public_auth_host`. That function
|
|
267
|
+
returns True for a DNS name, because rejecting unresolvable names would
|
|
268
|
+
refuse every legitimate ``auth.vendor.com``. But "not rejected" is not
|
|
269
|
+
"checked", and presenting the two identically is the vouch #211 reports.
|
|
270
|
+
|
|
271
|
+
A name therefore returns **False** here: PMCP does not resolve names -- a
|
|
272
|
+
deliberate non-goal, since a lookup is TOCTOU-vulnerable and is not SSRF
|
|
273
|
+
defence without connection-time IP pinning -- so it does not know where one
|
|
274
|
+
points and must not imply that it does.
|
|
275
|
+
"""
|
|
276
|
+
if hostname.lower() == "localhost":
|
|
277
|
+
return False
|
|
278
|
+
address: IPv4Address | IPv6Address | None
|
|
279
|
+
try:
|
|
280
|
+
address = ip_address(hostname)
|
|
281
|
+
except ValueError:
|
|
282
|
+
address = None
|
|
283
|
+
if address is not None:
|
|
284
|
+
return _is_public_ip(address)
|
|
285
|
+
numeric = _legacy_numeric_addresses(hostname)
|
|
286
|
+
if numeric:
|
|
287
|
+
return all(_is_public_ip(candidate) for candidate in numeric)
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def is_verified_public_auth_url(url: str) -> bool:
|
|
292
|
+
"""Report whether PMCP verified where an auth URL points.
|
|
293
|
+
|
|
294
|
+
True only for an absolute ``https://`` URL whose host PMCP classified as a
|
|
295
|
+
public IP literal. False for every DNS name, every non-public literal, and
|
|
296
|
+
every ``http://`` URL -- a cleartext hop's destination is not authenticated
|
|
297
|
+
even when the address is public.
|
|
298
|
+
|
|
299
|
+
Callers use this to decide what to *claim*, not what to accept: a relay path
|
|
300
|
+
keeps passing an unverified URL through, it just stops vouching for it.
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
parsed = urlparse(url)
|
|
304
|
+
hostname = parsed.hostname
|
|
305
|
+
_ = parsed.port
|
|
306
|
+
except ValueError:
|
|
307
|
+
return False
|
|
308
|
+
if parsed.scheme != "https" or not parsed.netloc or not hostname:
|
|
309
|
+
return False
|
|
310
|
+
return _is_verified_public_auth_host(hostname)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# One provenance-neutral sentence, used on both the downstream relay path and
|
|
314
|
+
# the operator acknowledgement path. The acknowledge path echoes a URL the
|
|
315
|
+
# operator typed, so the copy must not attribute it to "the server".
|
|
316
|
+
UNVERIFIED_URL_CAVEAT = (
|
|
317
|
+
"PMCP has not verified where this URL points -- it checks the URL's form "
|
|
318
|
+
"and rejects private address literals, but it does not resolve host names. "
|
|
319
|
+
"Confirm the destination yourself before opening it."
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
263
323
|
def sanitize_public_auth_url(url: str, *, allow_loopback_http: bool = False) -> str:
|
|
264
324
|
"""Validate and redact a public absolute auth metadata or elicitation URL."""
|
|
265
325
|
try:
|
|
@@ -482,10 +542,33 @@ def validate_resource_server_token(
|
|
|
482
542
|
)
|
|
483
543
|
|
|
484
544
|
|
|
485
|
-
def sanitize_url_elicitation_url(
|
|
486
|
-
|
|
545
|
+
def sanitize_url_elicitation_url(
|
|
546
|
+
url: str, *, provenance: Literal["remote", "operator"] = "remote"
|
|
547
|
+
) -> str:
|
|
548
|
+
"""Validate and redact a URL-mode elicitation URL.
|
|
549
|
+
|
|
550
|
+
This helper is shared by two call sites whose inputs have very different
|
|
551
|
+
provenance, and one permissiveness cannot be right for both (#211):
|
|
552
|
+
|
|
553
|
+
``remote`` (the default)
|
|
554
|
+
The URL came out of a downstream server's error payload. A loopback
|
|
555
|
+
``http://`` URL from there is an attempt to steer the operator at
|
|
556
|
+
something on the operator's own machine, so it is refused.
|
|
557
|
+
|
|
558
|
+
``operator``
|
|
559
|
+
The URL was typed by the operator into ``gateway.auth_connect``. Local
|
|
560
|
+
OAuth redirects back to ``http://127.0.0.1``, and refusing that would
|
|
561
|
+
break the frozen local-consent flow, so loopback HTTP stays allowed.
|
|
562
|
+
|
|
563
|
+
The default is the strict side deliberately: a call site this change misses
|
|
564
|
+
should lose loopback, not silently keep it. Returns ``str`` -- the frozen
|
|
565
|
+
IF-0-ELICIT-1 shape. Ask :func:`is_verified_public_auth_url` separately for
|
|
566
|
+
whether PMCP actually verified the destination.
|
|
567
|
+
"""
|
|
487
568
|
try:
|
|
488
|
-
return sanitize_public_auth_url(
|
|
569
|
+
return sanitize_public_auth_url(
|
|
570
|
+
url, allow_loopback_http=provenance == "operator"
|
|
571
|
+
)
|
|
489
572
|
except ValueError as exc:
|
|
490
573
|
raise ValueError("Invalid URL-mode elicitation URL.") from exc
|
|
491
574
|
|
|
@@ -585,6 +668,7 @@ def parse_www_authenticate(header: str) -> AuthChallengeInfo | None:
|
|
|
585
668
|
missing = params.get("missing_scope") or scope or ""
|
|
586
669
|
missing_scopes = [part for part in missing.split() if part]
|
|
587
670
|
resource_metadata_url = None
|
|
671
|
+
resource_metadata_url_verified = False
|
|
588
672
|
if params.get("resource_metadata"):
|
|
589
673
|
try:
|
|
590
674
|
resource_metadata_url = sanitize_public_auth_url(
|
|
@@ -592,10 +676,15 @@ def parse_www_authenticate(header: str) -> AuthChallengeInfo | None:
|
|
|
592
676
|
)
|
|
593
677
|
except ValueError:
|
|
594
678
|
resource_metadata_url = None
|
|
679
|
+
else:
|
|
680
|
+
resource_metadata_url_verified = is_verified_public_auth_url(
|
|
681
|
+
params["resource_metadata"]
|
|
682
|
+
)
|
|
595
683
|
|
|
596
684
|
return AuthChallengeInfo(
|
|
597
685
|
scheme=scheme,
|
|
598
686
|
resource_metadata_url=resource_metadata_url,
|
|
687
|
+
resource_metadata_url_verified=resource_metadata_url_verified,
|
|
599
688
|
scope=scope,
|
|
600
689
|
missing_scopes=missing_scopes,
|
|
601
690
|
error=params.get("error"),
|
|
@@ -633,18 +722,29 @@ def normalize_auth_metadata(
|
|
|
633
722
|
) -> AuthMetadataInfo:
|
|
634
723
|
"""Normalize untrusted authorization metadata into PMCP's public shape."""
|
|
635
724
|
normalized_diagnostics = [sanitize_auth_diagnostic(d) for d in (diagnostics or [])]
|
|
725
|
+
# Per-field, not per-object: these five URLs are independent, so one flag
|
|
726
|
+
# cannot be honest about a mix of a literal and a name (#211).
|
|
727
|
+
verified_urls: list[str] = []
|
|
636
728
|
|
|
637
729
|
def public_url(raw_url: str | None, field_name: str) -> str | None:
|
|
638
730
|
if not raw_url:
|
|
639
731
|
return None
|
|
640
732
|
try:
|
|
641
|
-
|
|
733
|
+
safe_url = sanitize_public_auth_url(raw_url)
|
|
642
734
|
except ValueError as exc:
|
|
643
735
|
normalized_diagnostics.append(
|
|
644
736
|
f"{field_name} ignored: {sanitize_auth_diagnostic(exc)} "
|
|
645
737
|
f"({redact_auth_url(raw_url)})"
|
|
646
738
|
)
|
|
647
739
|
return None
|
|
740
|
+
if is_verified_public_auth_url(raw_url):
|
|
741
|
+
verified_urls.append(field_name)
|
|
742
|
+
else:
|
|
743
|
+
normalized_diagnostics.append(
|
|
744
|
+
f"{field_name} is relayed unverified: PMCP did not resolve "
|
|
745
|
+
f"{safe_url} and cannot confirm where it points."
|
|
746
|
+
)
|
|
747
|
+
return safe_url
|
|
648
748
|
|
|
649
749
|
metadata = metadata or {}
|
|
650
750
|
scopes_supported = metadata.get("scopes_supported")
|
|
@@ -691,6 +791,7 @@ def normalize_auth_metadata(
|
|
|
691
791
|
granted_scopes=granted_scopes or [],
|
|
692
792
|
missing_scopes=missing_scopes or [],
|
|
693
793
|
diagnostics=normalized_diagnostics,
|
|
794
|
+
verified_urls=verified_urls,
|
|
694
795
|
)
|
|
695
796
|
|
|
696
797
|
|
|
@@ -733,20 +834,30 @@ def parse_url_elicitation_error(payload: object) -> list[UrlElicitationInfo]:
|
|
|
733
834
|
if not isinstance(elicitation_id, str) or not isinstance(url, str):
|
|
734
835
|
continue
|
|
735
836
|
try:
|
|
736
|
-
safe_url = sanitize_url_elicitation_url(url)
|
|
837
|
+
safe_url = sanitize_url_elicitation_url(url, provenance="remote")
|
|
737
838
|
except ValueError:
|
|
738
839
|
continue
|
|
739
840
|
message = entry.get("message")
|
|
841
|
+
verified = is_verified_public_auth_url(url)
|
|
842
|
+
# `next_step` is the string an agent actually follows, so the caveat has
|
|
843
|
+
# to live inside it. A `url_verified: false` field alongside an
|
|
844
|
+
# unqualified "Open the URL" instruction still gets the URL opened.
|
|
845
|
+
follow_up = (
|
|
846
|
+
"open the URL out of band, complete consent, then call "
|
|
847
|
+
f"gateway.auth_connect(auth_mode='url_elicitation', "
|
|
848
|
+
f"server_name='<server>', elicitation_id='{elicitation_id}', "
|
|
849
|
+
"consent_acknowledged=true)."
|
|
850
|
+
)
|
|
740
851
|
parsed_entries.append(
|
|
741
852
|
UrlElicitationInfo(
|
|
742
853
|
elicitation_id=elicitation_id,
|
|
743
854
|
url=safe_url,
|
|
855
|
+
url_verified=verified,
|
|
744
856
|
message=message if isinstance(message, str) else None,
|
|
745
857
|
next_step=(
|
|
746
|
-
"
|
|
747
|
-
|
|
748
|
-
f"
|
|
749
|
-
"consent_acknowledged=true)."
|
|
858
|
+
f"{follow_up[0].upper()}{follow_up[1:]}"
|
|
859
|
+
if verified
|
|
860
|
+
else f"{UNVERIFIED_URL_CAVEAT} Once confirmed, {follow_up}"
|
|
750
861
|
),
|
|
751
862
|
)
|
|
752
863
|
)
|
|
@@ -756,11 +867,28 @@ def parse_url_elicitation_error(payload: object) -> list[UrlElicitationInfo]:
|
|
|
756
867
|
def fetch_json_metadata(
|
|
757
868
|
url: str, *, timeout: float = 5.0
|
|
758
869
|
) -> tuple[dict[str, object] | None, str | None]:
|
|
759
|
-
"""Fetch public auth metadata without forwarding credentials.
|
|
870
|
+
"""Fetch public auth metadata without forwarding credentials.
|
|
871
|
+
|
|
872
|
+
This is the fail-closed side of #211. PMCP *retrieves* this URL itself, so
|
|
873
|
+
"accepted but unverified" is not good enough here the way it is on a relay
|
|
874
|
+
path: the host must be one PMCP classified as a public IP literal. Loopback
|
|
875
|
+
HTTP and unresolved names are both refused, and refused **before** the
|
|
876
|
+
opener is reached, so a rejected URL is never requested.
|
|
877
|
+
|
|
878
|
+
This gates the interface rather than removing it: ``fetch_json_metadata`` is
|
|
879
|
+
frozen by IF-0-SAFEURL-4 (``plans/phase-plan-v4-safeurl.md``), so deleting
|
|
880
|
+
it would break that freeze even though it has no production caller today.
|
|
881
|
+
"""
|
|
760
882
|
try:
|
|
761
|
-
safe_url = sanitize_public_auth_url(url
|
|
883
|
+
safe_url = sanitize_public_auth_url(url)
|
|
762
884
|
except ValueError as exc:
|
|
763
885
|
return None, sanitize_auth_diagnostic(exc)
|
|
886
|
+
if not is_verified_public_auth_url(url):
|
|
887
|
+
return None, (
|
|
888
|
+
f"{safe_url}: refused before fetching -- PMCP retrieves only URLs "
|
|
889
|
+
"whose host it verified as a public IP literal, and it does not "
|
|
890
|
+
"resolve host names."
|
|
891
|
+
)
|
|
764
892
|
try:
|
|
765
893
|
request = Request(safe_url, headers={"Accept": "application/json"})
|
|
766
894
|
with urlopen(request, timeout=timeout) as response:
|
|
@@ -962,6 +962,20 @@ def _sanitize_cli_payload(value: Any) -> Any:
|
|
|
962
962
|
return value
|
|
963
963
|
|
|
964
964
|
|
|
965
|
+
def _url_verification_suffix(elicitation: dict[str, Any]) -> str:
|
|
966
|
+
"""Qualify a displayed elicitation URL that PMCP did not verify.
|
|
967
|
+
|
|
968
|
+
Deliberately provenance-neutral: this is printed both for a URL relayed by a
|
|
969
|
+
downstream server and, on the acknowledge path, for one the operator typed,
|
|
970
|
+
so it must not claim the URL "came from the server". Absent or false
|
|
971
|
+
``url_verified`` both mean unverified -- an older gateway that does not send
|
|
972
|
+
the field must not be read as a clean bill of health (#211).
|
|
973
|
+
"""
|
|
974
|
+
if elicitation.get("url_verified") is True:
|
|
975
|
+
return ""
|
|
976
|
+
return " (destination not verified by pmcp)"
|
|
977
|
+
|
|
978
|
+
|
|
965
979
|
def _format_auth_evidence(
|
|
966
980
|
payload: dict[str, Any],
|
|
967
981
|
*,
|
|
@@ -997,6 +1011,11 @@ def _format_auth_evidence(
|
|
|
997
1011
|
]
|
|
998
1012
|
if ids:
|
|
999
1013
|
parts.append("elicitations=" + ",".join(ids))
|
|
1014
|
+
if any(
|
|
1015
|
+
isinstance(item, dict) and item.get("url_verified") is not True
|
|
1016
|
+
for item in url_elicitations
|
|
1017
|
+
):
|
|
1018
|
+
parts.append("url_destination=unverified")
|
|
1000
1019
|
|
|
1001
1020
|
next_step = payload.get("next_step")
|
|
1002
1021
|
if not next_step and semantics:
|
|
@@ -2513,7 +2532,10 @@ async def run_auth_connect(args: argparse.Namespace) -> None:
|
|
|
2513
2532
|
else:
|
|
2514
2533
|
print(first.get("message", "URL-mode elicitation required."))
|
|
2515
2534
|
if elicitation.get("url"):
|
|
2516
|
-
print(
|
|
2535
|
+
print(
|
|
2536
|
+
f"URL: {_redact_url_credentials(str(elicitation['url']))}"
|
|
2537
|
+
+ _url_verification_suffix(elicitation)
|
|
2538
|
+
)
|
|
2517
2539
|
if elicitation.get("elicitation_id"):
|
|
2518
2540
|
print(f"Elicitation ID: {elicitation['elicitation_id']}")
|
|
2519
2541
|
next_step = elicitation.get("next_step") or first.get("next_step")
|
|
@@ -2602,7 +2624,10 @@ async def run_auth_acknowledge(args: argparse.Namespace) -> None:
|
|
|
2602
2624
|
print(auth_result.get("message", "URL-mode elicitation acknowledged."))
|
|
2603
2625
|
url_elicitation = auth_result.get("url_elicitation")
|
|
2604
2626
|
if isinstance(url_elicitation, dict) and url_elicitation.get("url"):
|
|
2605
|
-
print(
|
|
2627
|
+
print(
|
|
2628
|
+
f"URL: {_redact_url_credentials(str(url_elicitation['url']))}"
|
|
2629
|
+
+ _url_verification_suffix(url_elicitation)
|
|
2630
|
+
)
|
|
2606
2631
|
if auth_result.get("next_step"):
|
|
2607
2632
|
print(f"Next step: {auth_result['next_step']}")
|
|
2608
2633
|
finally:
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from urllib.parse import urlparse
|
|
7
7
|
|
|
8
8
|
from pmcp.auth import (
|
|
9
|
+
is_verified_public_auth_url,
|
|
9
10
|
redact_auth_url,
|
|
10
11
|
sanitize_auth_diagnostic,
|
|
11
12
|
sanitize_public_auth_url,
|
|
@@ -87,11 +88,21 @@ def collect_remote_header_diagnostics(
|
|
|
87
88
|
)
|
|
88
89
|
)
|
|
89
90
|
else:
|
|
91
|
+
# `sanitize_public_auth_url` succeeding means the URL's form is
|
|
92
|
+
# valid and its host is not a private address literal -- not
|
|
93
|
+
# that pmcp knows where it points. Reporting a bare `[OK] ...
|
|
94
|
+
# configured at <name>` claimed the latter (#211).
|
|
95
|
+
qualifier = (
|
|
96
|
+
""
|
|
97
|
+
if is_verified_public_auth_url(metadata_url)
|
|
98
|
+
else " (host not verified; pmcp does not resolve names)"
|
|
99
|
+
)
|
|
90
100
|
checks.append(
|
|
91
101
|
(
|
|
92
102
|
"remote",
|
|
93
103
|
"ok",
|
|
94
|
-
f"{server_name}: {metadata_key} configured at
|
|
104
|
+
f"{server_name}: {metadata_key} configured at "
|
|
105
|
+
f"{safe_metadata_url}{qualifier}.",
|
|
95
106
|
)
|
|
96
107
|
)
|
|
97
108
|
|