Skip to content

Commit 77b0e93

Browse files
committed
Gate OAuth discovery to public URL only — fix Claude Code bearer token auth
Direct/Tailscale connections now get 404 for /.well-known/oauth-protected-resource, so Claude Code falls back to bearer token auth instead of trying OAuth with Keycloak. Caddy connections (via public URL host) still get OAuth metadata as before. Also fix test_default_settings to test model defaults directly instead of reading from .env (which may have overridden values).
1 parent fa89740 commit 77b0e93

2 files changed

Lines changed: 51 additions & 5 deletions

File tree

src/things_mcp/fast_server.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
patch_accept_headers,
3333
get_streamable_http_middleware,
3434
)
35-
from .settings import get_transport, is_debug_enabled
35+
from .settings import get_transport, is_debug_enabled, get_settings
3636
from .cache import get_cache_stats
3737
from .utils import app_state
3838
from .url_scheme import launch_things
@@ -319,11 +319,51 @@ async def dashboard_data(request):
319319
# Mount well-known routes at root level (RFC 9728 requires this).
320320
# Auth routes (e.g. /.well-known/oauth-protected-resource) must live
321321
# at the root, not under /mcp, so MCP clients can discover them.
322+
#
323+
# IMPORTANT: Only serve OAuth discovery metadata when accessed via the
324+
# public URL (Caddy reverse proxy). Direct/Tailscale connections should
325+
# NOT see OAuth metadata — they use bearer token auth instead.
326+
# Without this filter, Claude Code sees OAuth metadata and follows the
327+
# OAuth flow instead of using the configured bearer token header.
322328
if mcp_instance.auth:
329+
from starlette.responses import Response
330+
331+
public_host = (
332+
get_settings().things_mcp_public_url.rstrip("/").split("://")[-1]
333+
if get_settings().things_mcp_public_url
334+
else None
335+
)
336+
323337
auth_routes = mcp_instance.auth.get_routes("")
324-
routes.extend(auth_routes)
338+
if public_host:
339+
filtered_routes = []
340+
for route in auth_routes:
341+
if hasattr(route, "path") and ".well-known" in route.path:
342+
original_endpoint = route.endpoint
343+
344+
def _make_gated_endpoint(original):
345+
async def gated_endpoint(request):
346+
host = request.headers.get("host", "")
347+
if host == public_host or host.startswith(
348+
public_host.split(":")[0]
349+
):
350+
return await original(request)
351+
return Response(status_code=404)
352+
353+
return gated_endpoint
354+
355+
route.endpoint = _make_gated_endpoint(original_endpoint)
356+
filtered_routes.append(route)
357+
routes.extend(filtered_routes)
358+
else:
359+
routes.extend(auth_routes)
360+
325361
route_paths = [r.path for r in auth_routes if hasattr(r, "path")]
326-
logger.info("Auth routes mounted at root: %s", route_paths)
362+
logger.info(
363+
"Auth routes mounted at root: %s (gated to public host: %s)",
364+
route_paths,
365+
public_host,
366+
)
327367

328368
routes.append(Mount("/mcp", app=http_app, name="streamable-http"))
329369
logger.info(

tests/test_configuration.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77
from pydantic import ValidationError
88

9-
from things_mcp.settings import get_settings, Settings
9+
from things_mcp.settings import Settings
1010

1111

1212
@pytest.mark.unit
@@ -15,7 +15,13 @@ class TestConfiguration:
1515

1616
def test_default_settings(self):
1717
"""Default settings should be valid."""
18-
settings = get_settings()
18+
# Use Settings() directly to test model defaults, not get_settings()
19+
# which reads from .env and may have overridden values.
20+
settings = Settings(
21+
_env_file=None,
22+
things_auth_token="",
23+
things_mcp_api_key="",
24+
)
1925
assert settings.things_mcp_host == "127.0.0.1"
2026
assert settings.things_mcp_port == 8009
2127
assert settings.things_mcp_transport == "streamable-http"

0 commit comments

Comments
 (0)