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.
Files changed (307) hide show
  1. pmcp-2.7.3/.consiliency/plans/detailed-auth-trust-split-20260830-1600.md +199 -0
  2. {pmcp-2.7.2 → pmcp-2.7.3}/CHANGELOG.md +32 -0
  3. {pmcp-2.7.2 → pmcp-2.7.3}/PKG-INFO +8 -2
  4. {pmcp-2.7.2 → pmcp-2.7.3}/README.md +7 -1
  5. {pmcp-2.7.2 → pmcp-2.7.3}/SECURITY.md +11 -2
  6. {pmcp-2.7.2 → pmcp-2.7.3}/pyproject.toml +1 -1
  7. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/__init__.py +1 -1
  8. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/auth.py +140 -12
  9. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli.py +27 -2
  10. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/doctor.py +12 -1
  11. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/tools/handlers.py +15 -2
  12. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/types.py +40 -3
  13. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_auth.py +270 -6
  14. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_cli.py +200 -0
  15. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_ssrf_registry.py +5 -1
  16. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_tools.py +160 -0
  17. {pmcp-2.7.2 → pmcp-2.7.3}/uv.lock +1 -1
  18. {pmcp-2.7.2 → pmcp-2.7.3}/.claude/docs-catalog.json +0 -0
  19. {pmcp-2.7.2 → pmcp-2.7.3}/.claude/plans/ticklish-inventing-stearns.md +0 -0
  20. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/bypass-proofs-187.md +0 -0
  21. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-175.md +0 -0
  22. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-180.md +0 -0
  23. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-183.md +0 -0
  24. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/evidence/mutation-189.md +0 -0
  25. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/derive_npm_flags.py +0 -0
  26. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/differential_npm_corpus.py +0 -0
  27. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/mutate.py +0 -0
  28. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/sse-stream-ended-investigation-20260812.md +0 -0
  29. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/notes/verify_tables.py +0 -0
  30. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-anyio-transport-task-ownership-20260811-0204.md +0 -0
  31. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-catalog-indexing-hardening-20260827-1745.md +0 -0
  32. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-ci-job-timeouts-20260826-0400.md +0 -0
  33. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-ci-workflow-guards-20260826-2205.md +0 -0
  34. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-installed-version-from-runner-cache-20260817-1600.md +0 -0
  35. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-installed-version-from-serverinfo-20260817-0400.md +0 -0
  36. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-limit-bound-20260829-1500.md +0 -0
  37. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-nopt-backed-npm-identity-20260826-2150.md +0 -0
  38. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-npm-flag-classification-20260826-1130.md +0 -0
  39. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-package-identity-parsing-20260825-0900.md +0 -0
  40. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-policy-fail-open-20260829-1200.md +0 -0
  41. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-public-host-check-20260830-1200.md +0 -0
  42. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-remove-automatic-update-notices-20260819-0100.md +0 -0
  43. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-spawn-time-version-provenance-20260817-1800.md +0 -0
  44. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-stale-notice-package-identity-20260817-0300.md +0 -0
  45. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-update-server-resolution-residuals-20260816-1824.md +0 -0
  46. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-update-server-toctou-20260820-1300.md +0 -0
  47. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-value-flag-collisions-20260826-0500.md +0 -0
  48. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-verify-host-enumerated-types-20260827-1730.md +0 -0
  49. {pmcp-2.7.2 → pmcp-2.7.3}/.consiliency/plans/detailed-version-probe-ask-the-server-20260817-2000.md +0 -0
  50. {pmcp-2.7.2 → pmcp-2.7.3}/.dockerignore +0 -0
  51. {pmcp-2.7.2 → pmcp-2.7.3}/.env.example +0 -0
  52. {pmcp-2.7.2 → pmcp-2.7.3}/.github/CODEOWNERS +0 -0
  53. {pmcp-2.7.2 → pmcp-2.7.3}/.github/actions/pipeline-bootstrap-setup/action.yml +0 -0
  54. {pmcp-2.7.2 → pmcp-2.7.3}/.github/dependabot.yml +0 -0
  55. {pmcp-2.7.2 → pmcp-2.7.3}/.github/probe/p1_probe_client.py +0 -0
  56. {pmcp-2.7.2 → pmcp-2.7.3}/.github/probe/p1_probe_server.py +0 -0
  57. {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/docker.yml +0 -0
  58. {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/maintenance.yml +0 -0
  59. {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/pipeline-bootstrap.yml +0 -0
  60. {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/release.yml +0 -0
  61. {pmcp-2.7.2 → pmcp-2.7.3}/.github/workflows/test.yml +0 -0
  62. {pmcp-2.7.2 → pmcp-2.7.3}/.gitignore +0 -0
  63. {pmcp-2.7.2 → pmcp-2.7.3}/.mcp-gateway/descriptions.yaml +0 -0
  64. {pmcp-2.7.2 → pmcp-2.7.3}/.mcp.json.example +0 -0
  65. {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/fast-build-spark.md +0 -0
  66. {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/impl-codex53.md +0 -0
  67. {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/planner-gemini.md +0 -0
  68. {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/qa-debug.md +0 -0
  69. {pmcp-2.7.2 → pmcp-2.7.3}/.opencode/agent/research-spark.md +0 -0
  70. {pmcp-2.7.2 → pmcp-2.7.3}/.pr_details.md +0 -0
  71. {pmcp-2.7.2 → pmcp-2.7.3}/CONTRIBUTING.md +0 -0
  72. {pmcp-2.7.2 → pmcp-2.7.3}/Dockerfile +0 -0
  73. {pmcp-2.7.2 → pmcp-2.7.3}/LICENSE +0 -0
  74. {pmcp-2.7.2 → pmcp-2.7.3}/SPEC_COMPLIANCE.md +0 -0
  75. {pmcp-2.7.2 → pmcp-2.7.3}/agent-manifest.yaml +0 -0
  76. {pmcp-2.7.2 → pmcp-2.7.3}/catalog-info.yaml +0 -0
  77. {pmcp-2.7.2 → pmcp-2.7.3}/config/.mcp.json.example +0 -0
  78. {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/.gitignore +0 -0
  79. {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/RUNBOOK.md +0 -0
  80. {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/repro_client.py +0 -0
  81. {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/run.sh +0 -0
  82. {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/slow_server.py +0 -0
  83. {pmcp-2.7.2 → pmcp-2.7.3}/diagnostics/issue-79-1b/synthetic.mcp.json +0 -0
  84. {pmcp-2.7.2 → pmcp-2.7.3}/docker-compose.yml +0 -0
  85. {pmcp-2.7.2 → pmcp-2.7.3}/examples/gateway-only.mcp.json +0 -0
  86. {pmcp-2.7.2 → pmcp-2.7.3}/examples/gateway-policy.yaml +0 -0
  87. {pmcp-2.7.2 → pmcp-2.7.3}/examples/sample-backends.mcp.json +0 -0
  88. {pmcp-2.7.2 → pmcp-2.7.3}/examples/scoped-advisor-policy.yaml +0 -0
  89. {pmcp-2.7.2 → pmcp-2.7.3}/generated/.gitkeep +0 -0
  90. {pmcp-2.7.2 → pmcp-2.7.3}/plans/HANDOFF-agent-board-mcp-registration.md +0 -0
  91. {pmcp-2.7.2 → pmcp-2.7.3}/plans/ROADMAP-pmcp-agent-board-mcp.md +0 -0
  92. {pmcp-2.7.2 → pmcp-2.7.3}/plans/agent-board-overlay.template.jsonc +0 -0
  93. {pmcp-2.7.2 → pmcp-2.7.3}/plans/codebase-review-2026-06-15.md +0 -0
  94. {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-103-scoped-advisor-audit-20260726.md +0 -0
  95. {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-describe-nested-schema-and-indexit-stdio-20260706-0920.md +0 -0
  96. {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-idle-timeout-downstream-calls-20260628-0646.md +0 -0
  97. {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-mcp-2x-spec-2026-07-28-stage1-20260805-1740.md +0 -0
  98. {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-overlay-server-env-patch-20260801-1530.md +0 -0
  99. {pmcp-2.7.2 → pmcp-2.7.3}/plans/detailed-private-manifest-overlay-20260629-0841.md +0 -0
  100. {pmcp-2.7.2 → pmcp-2.7.3}/plans/manifest.json +0 -0
  101. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-config.md +0 -0
  102. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-observe.md +0 -0
  103. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-resolver.md +0 -0
  104. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v1-runtime.md +0 -0
  105. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P1.md +0 -0
  106. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P2.md +0 -0
  107. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P3B.md +0 -0
  108. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P5.md +0 -0
  109. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-P6CLEAN.md +0 -0
  110. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v11-PG.md +0 -0
  111. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v12-FANOUT.md +0 -0
  112. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v12-TRISTATE.md +0 -0
  113. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v12-UPDPATH.md +0 -0
  114. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-lifecycle.md +0 -0
  115. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-ops.md +0 -0
  116. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-refresh.md +0 -0
  117. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-serialize.md +0 -0
  118. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v2-soak.md +0 -0
  119. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-auth.md +0 -0
  120. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-config.md +0 -0
  121. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-conform.md +0 -0
  122. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-observe.md +0 -0
  123. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-proto.md +0 -0
  124. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v3-tasks.md +0 -0
  125. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-authobs.md +0 -0
  126. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-authsoak.md +0 -0
  127. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-elicit.md +0 -0
  128. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-remote.md +0 -0
  129. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-safeurl.md +0 -0
  130. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v4-store.md +0 -0
  131. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-catalogcli.md +0 -0
  132. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-clihint.md +0 -0
  133. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-climatch.md +0 -0
  134. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-clisoak.md +0 -0
  135. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v5-reqcli.md +0 -0
  136. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostcontract.md +0 -0
  137. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostmeta.md +0 -0
  138. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostpolicy.md +0 -0
  139. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostreg.md +0 -0
  140. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v6-hostsoak.md +0 -0
  141. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-AUTHRS.md +0 -0
  142. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-CONCURR.md +0 -0
  143. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-ENVFIX.md +0 -0
  144. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-MANIFEST.md +0 -0
  145. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-REDACT.md +0 -0
  146. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v7-REGISTRY.md +0 -0
  147. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-AUTHFIX.md +0 -0
  148. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-COMPLETE.md +0 -0
  149. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-MATCHFIX.md +0 -0
  150. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-REGFIX.md +0 -0
  151. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v8-RELEASE.md +0 -0
  152. {pmcp-2.7.2 → pmcp-2.7.3}/plans/phase-plan-v9-SPECCURRENCY.md +0 -0
  153. {pmcp-2.7.2 → pmcp-2.7.3}/plans/v7-code-review-findings.md +0 -0
  154. {pmcp-2.7.2 → pmcp-2.7.3}/plans/v7-issue-tracker.md +0 -0
  155. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/_mutate_workflow.sh +0 -0
  156. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/check_workflows.py +0 -0
  157. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/emit-event.mjs +0 -0
  158. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/package-lock.json +0 -0
  159. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/package.json +0 -0
  160. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/run.mjs +0 -0
  161. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/README.md +0 -0
  162. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/claude +0 -0
  163. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/codex +0 -0
  164. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/pipeline-bootstrap/shims/gemini +0 -0
  165. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/_serverkill_runner.py +0 -0
  166. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/run_sse_flake_probe.py +0 -0
  167. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/run_sse_flake_probe_interrupt.py +0 -0
  168. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe.py +0 -0
  169. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_interrupt.py +0 -0
  170. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_serverkill.py +0 -0
  171. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_sigkill.py +0 -0
  172. {pmcp-2.7.2 → pmcp-2.7.3}/scripts/probes/sse_flake_probe_sigkill_connect.py +0 -0
  173. {pmcp-2.7.2 → pmcp-2.7.3}/server.json +0 -0
  174. {pmcp-2.7.2 → pmcp-2.7.3}/specs/README.md +0 -0
  175. {pmcp-2.7.2 → pmcp-2.7.3}/specs/active/pmcp-gateway-orchestration-plan.md +0 -0
  176. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v1.md +0 -0
  177. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v10.md +0 -0
  178. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v11.md +0 -0
  179. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v12.md +0 -0
  180. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v2.md +0 -0
  181. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v3.md +0 -0
  182. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v4.md +0 -0
  183. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v5.md +0 -0
  184. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v6.md +0 -0
  185. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v7.md +0 -0
  186. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v8.md +0 -0
  187. {pmcp-2.7.2 → pmcp-2.7.3}/specs/phase-plans-v9.md +0 -0
  188. {pmcp-2.7.2 → pmcp-2.7.3}/specs/tenant-code-mode-host-contract.md +0 -0
  189. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/__main__.py +0 -0
  190. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/__init__.py +0 -0
  191. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/install.py +0 -0
  192. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/cli_commands/secrets.py +0 -0
  193. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/client/__init__.py +0 -0
  194. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/client/manager.py +0 -0
  195. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/config/__init__.py +0 -0
  196. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/config/guidance.py +0 -0
  197. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/config/loader.py +0 -0
  198. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/env_store.py +0 -0
  199. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/errors.py +0 -0
  200. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/identity.py +0 -0
  201. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/__init__.py +0 -0
  202. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/_npm_resolve.js +0 -0
  203. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/code_patterns.yaml +0 -0
  204. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/code_patterns_loader.py +0 -0
  205. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/environment.py +0 -0
  206. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/installer.py +0 -0
  207. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/loader.py +0 -0
  208. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/manifest.yaml +0 -0
  209. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/matcher.py +0 -0
  210. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/npm_resolver.py +0 -0
  211. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/refresher.py +0 -0
  212. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/registry.py +0 -0
  213. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/sync.py +0 -0
  214. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/manifest/version_checker.py +0 -0
  215. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/policy/__init__.py +0 -0
  216. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/policy/policy.py +0 -0
  217. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/py.typed +0 -0
  218. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/remote_auth.py +0 -0
  219. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/resources/code_execution_guide.md +0 -0
  220. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/scoped_advisor_audit.py +0 -0
  221. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/server.py +0 -0
  222. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/subscriptions.py +0 -0
  223. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/summary/__init__.py +0 -0
  224. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/summary/generator.py +0 -0
  225. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/summary/template_fallback.py +0 -0
  226. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/templates/code_examples.yaml +0 -0
  227. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/templates/code_snippets_loader.py +0 -0
  228. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/tools/__init__.py +0 -0
  229. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/transport/__init__.py +0 -0
  230. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/transport/http.py +0 -0
  231. {pmcp-2.7.2 → pmcp-2.7.3}/src/pmcp/validation.py +0 -0
  232. {pmcp-2.7.2 → pmcp-2.7.3}/team_announcement.md +0 -0
  233. {pmcp-2.7.2 → pmcp-2.7.3}/tests/__init__.py +0 -0
  234. {pmcp-2.7.2 → pmcp-2.7.3}/tests/conftest.py +0 -0
  235. {pmcp-2.7.2 → pmcp-2.7.3}/tests/fixtures/npm/schema.json +0 -0
  236. {pmcp-2.7.2 → pmcp-2.7.3}/tests/fixtures/registry/v0_servers_page1.json +0 -0
  237. {pmcp-2.7.2 → pmcp-2.7.3}/tests/fixtures/registry/v0_servers_page2.json +0 -0
  238. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_catalog_publishers.py +0 -0
  239. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_client_transport.py +0 -0
  240. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_dependency_bounds.py +0 -0
  241. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_get_retirement.py +0 -0
  242. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_listen_over_http.py +0 -0
  243. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_listen_registration.py +0 -0
  244. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_server_handlers.py +0 -0
  245. {pmcp-2.7.2 → pmcp-2.7.3}/tests/mcp2x/test_subscription_contract.py +0 -0
  246. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/__init__.py +0 -0
  247. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/conftest.py +0 -0
  248. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/fake_remote.py +0 -0
  249. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/fake_stdio_server.py +0 -0
  250. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/harness.py +0 -0
  251. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_downstream_handshake_era.py +0 -0
  252. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_downstream_remote.py +0 -0
  253. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_downstream_stdio.py +0 -0
  254. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_emitter_harness.py +0 -0
  255. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_get_retired.py +0 -0
  256. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_harness.py +0 -0
  257. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_publisher_coverage.py +0 -0
  258. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_subscriptions_e2e.py +0 -0
  259. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_wire_handshake_era.py +0 -0
  260. {pmcp-2.7.2 → pmcp-2.7.3}/tests/runtime/test_wire_modern_era.py +0 -0
  261. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_auth_origin_wiring.py +0 -0
  262. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_baseline_constraints.py +0 -0
  263. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_cli_p4.py +0 -0
  264. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_client_manager.py +0 -0
  265. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_client_manager_reconnect.py +0 -0
  266. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_config_loader.py +0 -0
  267. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_boot.py +0 -0
  268. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_child_env.py +0 -0
  269. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_gates_handlers.py +0 -0
  270. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_gates_startup.py +0 -0
  271. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_optionality_e2e.py +0 -0
  272. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_predicate_guard.py +0 -0
  273. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_credential_requirement.py +0 -0
  274. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_env_store.py +0 -0
  275. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_errors.py +0 -0
  276. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_guidance_config.py +0 -0
  277. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_http_dos.py +0 -0
  278. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_http_transport.py +0 -0
  279. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_identity.py +0 -0
  280. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_install_command.py +0 -0
  281. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_integration.py +0 -0
  282. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_lazy_start.py +0 -0
  283. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_manifest.py +0 -0
  284. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_manifest_overlay.py +0 -0
  285. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_manifest_provision.py +0 -0
  286. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_npm_resolver.py +0 -0
  287. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_offline_discovery.py +0 -0
  288. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_package_metadata.py +0 -0
  289. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_phase4_e2e.py +0 -0
  290. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_phase6_tenant_code_mode.py +0 -0
  291. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_policy.py +0 -0
  292. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_policy_fail_open.py +0 -0
  293. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_progressive_disclosure.py +0 -0
  294. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_provision_validation.py +0 -0
  295. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_refresher.py +0 -0
  296. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_registry.py +0 -0
  297. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_scoped_advisor_audit.py +0 -0
  298. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_secrets_command.py +0 -0
  299. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_server.py +0 -0
  300. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_server_lifecycle.py +0 -0
  301. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_setup_command.py +0 -0
  302. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_singleton_lock_scope.py +0 -0
  303. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_startup_resolver.py +0 -0
  304. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_summary.py +0 -0
  305. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_transport_http.py +0 -0
  306. {pmcp-2.7.2 → pmcp-2.7.3}/tests/test_version_checker.py +0 -0
  307. {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.2
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. Tracked in
76
- [#211](https://github.com/Consiliency/pmcp/issues/211).
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pmcp"
7
- version = "2.7.2"
7
+ version = "2.7.3"
8
8
  description = "PMCP - Progressive MCP: Minimal context bloat with on-demand tool discovery"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """PMCP - A meta-server for minimal Claude Code tool bloat."""
2
2
 
3
- __version__ = "2.7.2"
3
+ __version__ = "2.7.3"
@@ -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(url: str) -> str:
486
- """Validate and redact a URL-mode elicitation URL."""
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(url, allow_loopback_http=True)
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
- return sanitize_public_auth_url(raw_url)
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
- "Open the URL out of band, complete consent, then call "
747
- f"gateway.auth_connect(auth_mode='url_elicitation', "
748
- f"server_name='<server>', elicitation_id='{elicitation_id}', "
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, allow_loopback_http=True)
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(f"URL: {_redact_url_credentials(str(elicitation['url']))}")
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(f"URL: {_redact_url_credentials(str(url_elicitation['url']))}")
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 {safe_metadata_url}.",
104
+ f"{server_name}: {metadata_key} configured at "
105
+ f"{safe_metadata_url}{qualifier}.",
95
106
  )
96
107
  )
97
108