{"repo":"NousResearch/hermes-agent","public_id":"10271","display_label":"MCP OAuth strips URL path breaking resource validation","summary":"Multiple bug reports and fix attempts address an issue where MCP OAuth drops the path component from server URLs, causing protected resource validation failures against servers like Notion and GitHub. Several parallel fix PRs propose preserving the full server URL path when constructing the OAuthClientProvider.","subsystem":"mcp","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16031},"resolution":{"state":"partially_resolved","by":"pr-16031"},"freshness":{"state":"verified","last_verified_at":"2026-04-26T12:43:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22577777777777777},"fix_edges":[{"issue_number":10271,"pr_number":16031,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":11838,"pr_number":16031,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":16015,"pr_number":16031,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":10271,"title":"[Bug]: OAuth 2.1 PKCE fails for servers with a path in their endpoint URL (RFC 8707 resource mismatch)","url":null,"state":"closed"},{"kind":"issue","number":11807,"title":"MCP OAuth: server_url path stripped breaks GitHub MCP resource validation","url":null,"state":"closed"},{"kind":"issue","number":11838,"title":"MCP OAuth: server_url path stripped breaks GitHub MCP resource validation","url":null,"state":"closed"},{"kind":"issue","number":16015,"title":"MCP OAuth strips /mcp from server_url, breaking Notion protected resource validation","url":null,"state":"closed"},{"kind":"pr","number":10278,"title":"fix(mcp): pass full server URL to OAuthClientProvider, strip trailing slash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d09ab8ff1","number":0},{"kind":"pr","value":"#16031","number":16031}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10932,"title":"fix(mcp): preserve OAuth server URL paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d09ab8ff13329da1715d20e3fb17d47f499fbc18","number":0},{"kind":"pr","value":"#16031","number":16031}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":11808,"title":"fix: preserve server URL path in MCP OAuth resource matching","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":11837,"title":"fix: preserve server URL path in MCP OAuth resource matching","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d09ab8ff13329da1715d20e3fb17d47f499fbc18","number":0},{"kind":"pr","value":"#16031","number":16031}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15747,"title":"fix(mcp-oauth): preserve server URL path when constructing OAuthClientProvider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10932","number":10932},{"kind":"pr","value":"#13230","number":13230},{"kind":"pr","value":"#13853","number":13853},{"kind":"pr","value":"#10271","number":10271}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":16031,"title":"fix(mcp-oauth): preserve server_url path for protected-resource validation","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_oauth.py","tools/mcp_oauth.py","tools/mcp_oauth_manager.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -491,11 +491,36 @@ def test_configure_callback_port_uses_explicit_port():\n     assert cfg[\"_resolved_port\"] == 54321\n \n \n-def test_parse_base_url_strips_path():\n-    \"\"\"_parse_base_url drops path components for OAuth discovery.\"\"\"\n-    from tools.mcp_oauth import _parse_base_url\n+def test_build_oauth_auth_preserves_server_url_path():\n+    \"\"\"server_url with path is forwarded to OAuthClientProvider unmodified.\n+\n+    Regression for #16015: previously ``_parse_base_url`` stripped the path,\n+    collapsing ``https://mcp.notion.com/mcp`` to ``https://mcp.notion.com`` and\n+    breaking RFC 9728 protected-resource validation against servers whose PRM\n+    advertises a path-scoped resource (Notion). The MCP SDK strips the path\n+    itself for authorization-server discovery via\n+    ``OAuthContext.get_authorization_base_url``; Hermes must not pre-strip.\n+    \"\"\"\n+    from tools import mcp_oauth\n+\n+    captured: dict = {}\n+\n+    class _FakeProvider:\n+        def __init__(self, **kwargs):\n+            captured.update(kwargs)\n+\n+    with patch.object(mcp_oauth, \"_OAUTH_AVAILABLE\", True), \\\n+         patch.object(mcp_oauth, \"OAuthClientProvider\", _FakeProvider), \\\n+         patch.object(mcp_oauth, \"_is_interactive\", return_value=True), \\\n+         patch.object(mcp_oauth, \"_maybe_preregister_client\"), \\\n+         patch.object(mcp_oauth, \"HermesTokenStorage\") as mock_storage_cls:\n+        mock_storage_cls.return_value = MagicMock(has_cached_tokens=[redacted:secret-assignment] True)\n+        build_oauth_auth(\n+            server_name=\"notion\",\n+            server_url=\"https://mcp.notion.com/mcp\",\n+            oauth_config={},\n+        )\n+\n+    assert captured[\"server_url\"] == \"https://mcp.notion.com/mcp\"\n \n-    assert _parse_base_url(\"https://example.com/mcp/v1\") == \"https://example.com\"\n-    assert _parse_base_url(\"https://example.com\") == \"https://example.com\"\n-    assert _parse_base_url(\"https://host.example.com:8080/api\") == \"https://host.example.com:8080\"\n "]},{"file":"tools/mcp_oauth.py","hunks":["@@ -519,12 +519,6 @@ def _maybe_preregister_client(\n     logger.debug(\"Pre-registered client_id=%s for '%s'\", client_id, storage._server_name)\n \n \n-def _parse_base_url(server_url: str) -> str:\n-    \"\"\"Strip path component from server URL, returning the base origin.\"\"\"\n-    parsed = urlparse(server_url)\n-    return f\"{parsed.scheme}://{parsed.netloc}\"\n-\n-\n def build_oauth_auth(\n     server_name: str,\n     server_url: str,","@@ -570,7 +564,7 @@ def build_oauth_auth(\n     _maybe_preregister_client(storage, cfg, client_metadata)\n \n     return OAuthClientProvider(\n-        server_url=_parse_base_url(server_url),\n+        server_url=server_url,\n         client_metadata=client_metadata,\n         storage=storage,\n         redirect_handler=_redirect_handler,"]},{"file":"tools/mcp_oauth_manager.py","hunks":["@@ -362,7 +362,6 @@ def _build_provider(\n             _configure_callback_port,\n             _is_interactive,\n             _maybe_preregister_client,\n-            _parse_base_url,\n             _redirect_handler,\n             _wait_for_callback,\n         )","@@ -387,7 +386,7 @@ def _build_provider(\n \n         return _HERMES_PROVIDER_CLS(\n             server_name=server_name,\n-            server_url=_parse_base_url(entry.server_url),\n+            server_url=entry.server_url,\n             client_metadata=client_metadata,\n             storage=storage,\n             redirect_handler=_redirect_handler,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}