release: remote HTTP MCP — bearer + OAuth, Docker image, CI publish, per-client docs #65

Merged
Latte merged 16 commits from dev into main 2026-07-05 15:50:53 +00:00
3 changed files with 343 additions and 14 deletions
Showing only changes of commit 7bb99d94bf - Show all commits

No files matched your search

+63
View File
@@ -133,6 +133,69 @@ def http_token() -> str:
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip() return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip()
# Hosts that are *not* reachable from off-box. A bind to anything else is a
# public interface and must be authenticated (or explicitly waived).
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", ""}
def is_loopback(host: str) -> bool:
"""True if binding ``host`` only exposes the server to the local machine."""
return (host or "").strip().lower() in LOOPBACK_HOSTS
def _env_truthy(name: str) -> bool:
return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"}
def allow_insecure_bind() -> bool:
"""Escape hatch: allow a public (non-loopback) bind without a token.
Off by default. Only honor ``FAMILIAR_ALLOW_INSECURE_BIND`` when the operator
really means to run unauthenticated (e.g. auth handled entirely upstream).
"""
return _env_truthy("FAMILIAR_ALLOW_INSECURE_BIND")
# --------------------------------------------------------------------------- #
# HTTP auth mode: bearer (default) or oauth (OAuth 2.1 resource server).
# --------------------------------------------------------------------------- #
def auth_mode() -> str:
"""``bearer`` (default) or ``oauth`` — how the HTTP transport authenticates.
``bearer``: a single static token in ``FAMILIAR_HTTP_TOKEN``.
``oauth``: validate external-issuer JWTs (JWKS) and advertise the
protected-resource metadata so OAuth-only clients (e.g. Claude web) can
discover the authorization server.
"""
mode = (os.environ.get("FAMILIAR_AUTH_MODE") or "bearer").strip().lower()
return mode if mode in {"bearer", "oauth"} else "bearer"
def oauth_issuer() -> str:
return (os.environ.get("FAMILIAR_OAUTH_ISSUER") or "").strip()
def oauth_audience() -> str:
"""Expected token audience. Defaults to the resource URL when unset."""
aud = (os.environ.get("FAMILIAR_OAUTH_AUDIENCE") or "").strip()
return aud or oauth_resource()
def oauth_resource() -> str:
"""This server's public base URL (the OAuth *resource*), e.g. https://host.
Used as the RemoteAuthProvider base_url and advertised in the
protected-resource metadata. Falls back to FAMILIAR_HTTP_PATH's origin only
if explicitly set; otherwise empty (serve will require it in oauth mode).
"""
return (os.environ.get("FAMILIAR_OAUTH_RESOURCE") or "").strip()
def oauth_jwks_uri() -> str:
"""Explicit JWKS URI, or empty to discover it from the issuer at start."""
return (os.environ.get("FAMILIAR_OAUTH_JWKS_URI") or "").strip()
def data_dir() -> Path: def data_dir() -> Path:
"""Where the encrypted token and key live. Created with restricted perms.""" """Where the encrypted token and key live. Created with restricted perms."""
override = os.environ.get("FAMILIAR_DATA_DIR") override = os.environ.get("FAMILIAR_DATA_DIR")
+103 -14
View File
@@ -8,8 +8,11 @@ same `mcp` instance.
from __future__ import annotations from __future__ import annotations
import datetime as _dt import datetime as _dt
import sys
from fastmcp import FastMCP from fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
from . import ( from . import (
__version__, __version__,
@@ -47,6 +50,16 @@ mcp: FastMCP = FastMCP(
) )
@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(_request: Request) -> JSONResponse:
"""Unauthenticated liveness probe for Docker / reverse-proxy healthchecks.
Only reachable over the HTTP transport; on stdio the route is never served.
Deliberately outside the bearer-auth gate so a probe needs no token.
"""
return JSONResponse({"status": "ok", "server": "familiar", "version": __version__})
@mcp.tool @mcp.tool
def ping() -> dict[str, str]: def ping() -> dict[str, str]:
"""Health check. Confirms the familiar server is alive and reports its version. """Health check. Confirms the familiar server is alive and reports its version.
@@ -126,21 +139,96 @@ def _bearer_verifier(token: str):
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}}) return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
def configure_http(token: str | None = None) -> str: def _resolve_jwks_uri(issuer: str, jwks_uri: str) -> str:
"""Prepare the shared ``mcp`` instance for HTTP serving and return the token. """Return the JWKS URI, discovering it from the issuer's OIDC metadata if unset."""
if jwks_uri:
return jwks_uri
import httpx
Attaches bearer auth and switches writes to their network default (disabled disco = issuer.rstrip("/") + "/.well-known/openid-configuration"
unless FAMILIAR_ENABLE_WRITES is explicitly truthy). Raises ``SystemExit`` try:
if no token is configured — an internet-reachable server must never run open. resp = httpx.get(disco, timeout=10.0)
resp.raise_for_status()
uri = resp.json().get("jwks_uri")
except Exception as exc: # noqa: BLE001 — surface any discovery failure as a clean exit
raise SystemExit(
f"OAuth discovery failed at {disco}: {exc}. Set FAMILIAR_OAUTH_JWKS_URI explicitly."
) from exc
if not uri:
raise SystemExit(f"OIDC metadata at {disco} has no jwks_uri; set FAMILIAR_OAUTH_JWKS_URI.")
return uri
def _oauth_provider(verifier=None):
"""Build the OAuth 2.1 resource-server provider (validates external JWTs).
familiar acts as a *resource server* only: it validates incoming JWT access
tokens against an external issuer's JWKS and advertises
``/.well-known/oauth-protected-resource`` so OAuth-only clients can discover
the authorization server. It is NOT an authorization server and does no DCR.
``verifier`` is injectable for tests; production builds a JWTVerifier.
""" """
from pydantic import AnyHttpUrl
from fastmcp.server.auth import RemoteAuthProvider
issuer = config.oauth_issuer()
resource = config.oauth_resource()
if not issuer or not resource:
raise SystemExit(
"oauth mode needs FAMILIAR_OAUTH_ISSUER (the authorization server) and "
"FAMILIAR_OAUTH_RESOURCE (this server's public https URL)."
)
if verifier is None:
from fastmcp.server.auth.providers.jwt import JWTVerifier
jwks = _resolve_jwks_uri(issuer, config.oauth_jwks_uri())
verifier = JWTVerifier(jwks_uri=jwks, issuer=issuer, audience=config.oauth_audience())
return RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl(issuer)],
base_url=resource,
)
def _refuse_no_token(host: str) -> "SystemExit":
where = "a public interface" if not config.is_loopback(host) else "loopback"
return SystemExit(
f"Refusing to start the HTTP server on {host!r} ({where}) without a bearer "
"token. Set FAMILIAR_HTTP_TOKEN to a long random secret, switch to "
"FAMILIAR_AUTH_MODE=oauth, or — only if auth is handled upstream — set "
"FAMILIAR_ALLOW_INSECURE_BIND=true to run unauthenticated."
)
def configure_http(*, host: str | None = None, token: str | None = None, verifier=None) -> str:
"""Prepare the shared ``mcp`` instance for HTTP serving; return the mode/token.
Attaches the auth provider for the configured ``FAMILIAR_AUTH_MODE`` and
switches writes to their network default (disabled unless
FAMILIAR_ENABLE_WRITES is explicitly truthy). Fail-closed: a bind without a
token (bearer mode) raises ``SystemExit`` unless FAMILIAR_ALLOW_INSECURE_BIND
is set. ``verifier`` is injectable for oauth-mode tests.
"""
host = host if host is not None else config.HTTP_HOST
writes.set_http_context(True)
if config.auth_mode() == "oauth":
mcp.auth = _oauth_provider(verifier=verifier)
return "oauth"
token = token if token is not None else config.http_token() token = token if token is not None else config.http_token()
if not token: if not token:
raise SystemExit( if config.allow_insecure_bind():
"Refusing to start the HTTP server: FAMILIAR_HTTP_TOKEN is not set. " print(
"Set it to a long random secret (the bearer token your MCP client " f"WARNING: serving familiar on {host!r} with NO authentication "
"sends) and try again." "(FAMILIAR_ALLOW_INSECURE_BIND is set). Anyone who can reach this "
"port has full access to your account.",
file=sys.stderr,
) )
writes.set_http_context(True) mcp.auth = None
return ""
raise _refuse_no_token(host)
mcp.auth = _bearer_verifier(token) mcp.auth = _bearer_verifier(token)
return token return token
@@ -148,16 +236,17 @@ def configure_http(token: str | None = None) -> str:
def serve_http( def serve_http(
*, host: str | None = None, port: int | None = None, path: str | None = None *, host: str | None = None, port: int | None = None, path: str | None = None
) -> None: ) -> None:
"""Run familiar over the streamable-HTTP transport with bearer auth. """Run familiar over the streamable-HTTP transport.
Binds loopback by default (a TLS reverse proxy is expected to terminate Binds loopback by default (a TLS reverse proxy is expected to terminate
HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_* HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_*
config. Refuses to start without FAMILIAR_HTTP_TOKEN. config. Authentication follows FAMILIAR_AUTH_MODE (bearer by default).
""" """
configure_http() host = host or config.HTTP_HOST
configure_http(host=host)
mcp.run( mcp.run(
transport="http", transport="http",
host=host or config.HTTP_HOST, host=host,
port=port or config.HTTP_PORT, port=port or config.HTTP_PORT,
path=path or config.HTTP_PATH, path=path or config.HTTP_PATH,
) )
+177
View File
@@ -79,6 +79,20 @@ def test_valid_token_passes_handshake(http_server):
assert status == 200 assert status == 200
def test_healthz_needs_no_token(http_server):
_, app = http_server
async def _get_health():
transport = httpx.ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/healthz")
resp = asyncio.run(_get_health())
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Start refusal without a token # Start refusal without a token
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -214,3 +228,166 @@ def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path):
assert bundle["character_id"] == 42 assert bundle["character_id"] == 42
assert bundle["character_name"] == "Pilot" assert bundle["character_name"] == "Pilot"
assert saved["character_id"] == 42 assert saved["character_id"] == 42
# --------------------------------------------------------------------------- #
# Fail-closed bind + insecure escape hatch
# --------------------------------------------------------------------------- #
def _reset_auth():
server.mcp.auth = None
writes.set_http_context(False)
def test_public_bind_without_token_refused(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
try:
with pytest.raises(SystemExit) as excinfo:
server.configure_http(host="0.0.0.0")
assert "public interface" in str(excinfo.value)
finally:
_reset_auth()
def test_loopback_without_token_still_refused(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
try:
with pytest.raises(SystemExit):
server.configure_http(host="127.0.0.1")
finally:
_reset_auth()
def test_insecure_bind_escape_hatch_allows_tokenless(monkeypatch):
monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False)
monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "true")
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
try:
result = server.configure_http(host="0.0.0.0")
assert result == ""
assert server.mcp.auth is None
finally:
_reset_auth()
# --------------------------------------------------------------------------- #
# OAuth resource-server mode
# --------------------------------------------------------------------------- #
_ISS = "https://idp.example.com"
_RES = "https://familiar.example.com"
@pytest.fixture
def oauth_server(monkeypatch):
"""Configure oauth mode with an injected JWTVerifier over a local RSA key."""
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth")
monkeypatch.setenv("FAMILIAR_OAUTH_ISSUER", _ISS)
monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES)
monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", _RES)
original = server.mcp.auth
kp = RSAKeyPair.generate()
verifier = JWTVerifier(public_key=kp.public_key, issuer=_ISS, audience=_RES)
server.configure_http(host="0.0.0.0", verifier=verifier)
app = server.mcp.http_app(path="/mcp")
yield kp, app
server.mcp.auth = original
writes.set_http_context(False)
def test_oauth_valid_jwt_passes(oauth_server):
kp, app = oauth_server
token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1")
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 200
def test_oauth_missing_token_401(oauth_server):
_, app = oauth_server
status, www_auth = asyncio.run(_status_for(app, None))
assert status == 401
assert www_auth and www_auth.lower().startswith("bearer")
def test_oauth_wrong_audience_401(oauth_server):
kp, app = oauth_server
token = kp.create_token(issuer=_ISS, audience="https://other.example.com", subject="user1")
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 401
def test_oauth_expired_token_401(oauth_server):
kp, app = oauth_server
token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1", expires_in_seconds=-60)
status, _ = asyncio.run(_status_for(app, f"Bearer {token}"))
assert status == 401
def test_oauth_protected_resource_metadata(oauth_server):
_, app = oauth_server
async def _get_metadata():
transport = httpx.ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/.well-known/oauth-protected-resource/mcp")
resp = asyncio.run(_get_metadata())
assert resp.status_code == 200
body = resp.json()
assert body["resource"] == f"{_RES}/mcp"
assert body["authorization_servers"] == [f"{_ISS}/"]
def test_oauth_requires_issuer_and_resource(monkeypatch):
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth")
monkeypatch.delenv("FAMILIAR_OAUTH_ISSUER", raising=False)
monkeypatch.delenv("FAMILIAR_OAUTH_RESOURCE", raising=False)
try:
with pytest.raises(SystemExit) as excinfo:
server.configure_http(host="0.0.0.0")
assert "FAMILIAR_OAUTH_ISSUER" in str(excinfo.value)
finally:
_reset_auth()
def test_resolve_jwks_uri_passthrough():
assert server._resolve_jwks_uri(_ISS, "https://idp/keys") == "https://idp/keys"
# --------------------------------------------------------------------------- #
# Config readers for the new env vars
# --------------------------------------------------------------------------- #
def test_is_loopback():
assert config.is_loopback("127.0.0.1")
assert config.is_loopback("localhost")
assert not config.is_loopback("0.0.0.0")
assert not config.is_loopback("192.168.1.10")
def test_auth_mode_default_and_values(monkeypatch):
monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False)
assert config.auth_mode() == "bearer"
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "OAuth")
assert config.auth_mode() == "oauth"
monkeypatch.setenv("FAMILIAR_AUTH_MODE", "nonsense")
assert config.auth_mode() == "bearer"
def test_allow_insecure_bind(monkeypatch):
monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False)
assert config.allow_insecure_bind() is False
monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "yes")
assert config.allow_insecure_bind() is True
def test_oauth_audience_defaults_to_resource(monkeypatch):
monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES)
monkeypatch.delenv("FAMILIAR_OAUTH_AUDIENCE", raising=False)
assert config.oauth_audience() == _RES
monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", "custom-aud")
assert config.oauth_audience() == "custom-aud"