feat(serve): opt-in HTTP transport with bearer auth + headless login

Add a `familiar serve` subcommand that runs the FastMCP streamable-HTTP
transport for use as a remote MCP server (e.g. a Mistral custom connector),
while stdio stays the default and is completely unchanged.

- config: FAMILIAR_HTTP_HOST/PORT/PATH (default 127.0.0.1:8080/mcp) and a
  live http_token() reader for FAMILIAR_HTTP_TOKEN.
- server: configure_http()/serve_http() attach a StaticTokenVerifier so a
  missing/wrong bearer token gets 401 + WWW-Authenticate: Bearer and a valid
  one passes the handshake. Refuses to start (SystemExit) without a token.
- writes: in HTTP mode the write default flips to disabled unless
  FAMILIAR_ENABLE_WRITES is explicitly truthy; guard() logic is untouched.
- auth: manual_login() + parse_callback() power `login --no-browser`, printing
  the SSO URL and reading the pasted callback from stdin (no local browser).
- bump fastmcp pin to >=3.0.0 for the streamable-HTTP + verifier API.
This commit is contained in:
Latte committed 2026-07-04 18:20:59 +02:00
1 parent d65e9be5ff
commit 458e31685f
7 files changed
+160 -7

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11"
license = { file = "LICENSE" }
dependencies = [
"fastmcp>=2.3.0",
"fastmcp>=3.0.0", # streamable-HTTP transport + StaticTokenVerifier bearer auth
"httpx>=0.27.0",
"cryptography>=42.0.0",
]
+14 -1
View File
@@ -4,7 +4,10 @@ With no arguments it runs the MCP server over stdio (how Claude Desktop /
Claude Code / Cowork launch it). The auth subcommands are meant to be run by
hand in a terminal:
familiar # run the MCP server over stdio (default)
familiar serve # run the streamable-HTTP transport (remote MCP)
familiar login # add a character via EVE SSO (run once per character)
familiar login --no-browser # headless login: paste the callback URL on stdin
familiar status # list logged-in characters
familiar logout # forget ALL characters
familiar logout NAME # forget one character (by name or id)
@@ -35,13 +38,22 @@ def _human_size(num: int) -> str:
def _login() -> int:
bundle = auth.interactive_login()
no_browser = any(a in {"--no-browser", "--headless"} for a in sys.argv[2:])
bundle = auth.manual_login() if no_browser else auth.interactive_login()
print(f"Added {bundle.get('character_name')} (id {bundle.get('character_id')}).")
chars = auth.list_characters()
print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}")
return 0
def _serve() -> int:
"""Run the streamable-HTTP transport (remote MCP). Refuses without a token."""
from .server import serve_http
serve_http()
return 0
def _status() -> int:
chars = auth.list_characters()
if not chars:
@@ -101,6 +113,7 @@ def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else None
handlers = {
"login": _login,
"serve": _serve,
"status": _status,
"logout": _logout,
"writes": _writes,
+47
View File
@@ -16,6 +16,7 @@ import os
import secrets
import time
import webbrowser
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
@@ -328,6 +329,52 @@ def interactive_login(*, open_browser: bool = True) -> dict[str, Any]:
return bundle
def parse_callback(raw: str) -> tuple[str, str | None]:
"""Pull ``(code, state)`` out of a pasted callback URL, query, or bare code.
Accepts the full redirect URL (``http://localhost:8765/callback?code=..&state=..``),
just the query string, or the bare authorization code. Returns the code and
the state if one was present (``None`` otherwise). Raises if no code is found.
"""
raw = raw.strip()
if "code=" in raw:
query = urlparse(raw).query or raw
qs = parse_qs(query)
code = qs.get("code", [None])[0]
state = qs.get("state", [None])[0]
else:
code, state = (raw or None), None
if not code:
raise AuthError("No authorization code found in the pasted callback.")
return code, state
def manual_login(*, read_input: Callable[[str], str] = input) -> dict[str, Any]:
"""Headless PKCE login: print the authorize URL, read the callback from stdin.
For servers with no local browser. Prints the EVE SSO URL (open it anywhere),
then reads the pasted callback URL — or a bare code — from ``read_input``.
The browser's redirect to the (unreachable) localhost callback fails
harmlessly; the code is in its address bar. No local listener is started.
"""
verifier, challenge = generate_pkce()
state = secrets.token_urlsafe(16)
url = build_authorize_url(state, challenge)
print(
"Open this URL in any browser, authorize, then copy the address bar of "
"the page it redirects to (the localhost page will fail to load — that's "
"expected):\n\n"
f"{url}\n"
)
code, got_state = parse_callback(read_input("Paste the callback URL (or code): "))
if got_state is not None and got_state != state:
raise AuthError("State mismatch — possible CSRF; aborting login.")
resp = asyncio.run(exchange_code(code, verifier))
bundle = _bundle_from_response(resp, now=time.time())
_save_character(bundle)
return bundle
def status() -> dict[str, Any]:
"""Report which characters are logged in (no network call)."""
chars = list_characters()
+21
View File
@@ -111,6 +111,27 @@ WRITE_SCOPES = [
SCOPES = os.environ.get("FAMILIAR_SCOPES", " ".join(READ_SCOPES + WRITE_SCOPES)).split()
# --------------------------------------------------------------------------- #
# HTTP (remote MCP) transport — opt-in via `familiar serve`.
# --------------------------------------------------------------------------- #
# stdio stays the default (how Claude launches familiar locally). `familiar
# serve` runs the streamable-HTTP transport instead, meant to sit behind a TLS
# reverse proxy which terminates HTTPS — so the default bind is loopback. Every
# value is env-overridable; the token has no default on purpose (see
# ``http_token`` — HTTP mode refuses to start without one).
HTTP_HOST = os.environ.get("FAMILIAR_HTTP_HOST", "127.0.0.1")
HTTP_PORT = int(os.environ.get("FAMILIAR_HTTP_PORT", "8080"))
HTTP_PATH = os.environ.get("FAMILIAR_HTTP_PATH", "/mcp")
def http_token() -> str:
"""The expected bearer token for the HTTP transport (empty if unset).
Read live from ``FAMILIAR_HTTP_TOKEN`` so `familiar serve` can refuse to
start when it is missing rather than exposing an unauthenticated server.
"""
return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip()
def data_dir() -> Path:
"""Where the encrypted token and key live. Created with restricted perms."""
+54
View File
@@ -15,6 +15,7 @@ from . import (
__version__,
account,
comms,
config,
contacts,
corp,
corp_economy,
@@ -107,3 +108,56 @@ ui.register(mcp)
# Phase 4: planetary interaction (read-only).
planets.register(mcp)
# --------------------------------------------------------------------------- #
# Remote (HTTP) transport — opt-in via `familiar serve`. stdio stays default.
# --------------------------------------------------------------------------- #
def _bearer_verifier(token: str):
"""A FastMCP verifier that accepts exactly one bearer token.
Uses FastMCP's built-in StaticTokenVerifier: a request with a matching
``Authorization: Bearer <token>`` passes; anything else gets a 401 with a
``WWW-Authenticate: Bearer`` header. Imported lazily so the stdio path never
pulls in the auth stack.
"""
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}})
def configure_http(token: str | None = None) -> str:
"""Prepare the shared ``mcp`` instance for HTTP serving and return the token.
Attaches bearer auth and switches writes to their network default (disabled
unless FAMILIAR_ENABLE_WRITES is explicitly truthy). Raises ``SystemExit``
if no token is configured — an internet-reachable server must never run open.
"""
token = token if token is not None else config.http_token()
if not token:
raise SystemExit(
"Refusing to start the HTTP server: FAMILIAR_HTTP_TOKEN is not set. "
"Set it to a long random secret (the bearer token your MCP client "
"sends) and try again."
)
writes.set_http_context(True)
mcp.auth = _bearer_verifier(token)
return token
def serve_http(
*, host: str | None = None, port: int | None = None, path: str | None = None
) -> None:
"""Run familiar over the streamable-HTTP transport with bearer auth.
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_*
config. Refuses to start without FAMILIAR_HTTP_TOKEN.
"""
configure_http()
mcp.run(
transport="http",
host=host or config.HTTP_HOST,
port=port or config.HTTP_PORT,
path=path or config.HTTP_PATH,
)
+22 -4
View File
@@ -28,15 +28,33 @@ def _flag_file():
return config.data_dir() / "writes_disabled"
def writes_enabled() -> bool:
"""True if writes are allowed (env override or the on-disk toggle).
# Flipped on by `familiar serve` (HTTP transport). It only changes what the
# *default* is when FAMILIAR_ENABLE_WRITES is unset: a network-reachable,
# LLM-driven endpoint should not send mail or set waypoints unless the operator
# opted in explicitly, so in HTTP mode the default is disabled rather than
# enabled. An explicit env value (either way) still wins.
_http_context = False
Writes are **enabled by default**; the user opts out by creating the
disable-flag file (`familiar writes disable`) or setting the env var.
def set_http_context(enabled: bool) -> None:
"""Mark the process as serving over HTTP so writes default to disabled."""
global _http_context
_http_context = enabled
def writes_enabled() -> bool:
"""True if writes are allowed (env override, HTTP default, or on-disk toggle).
Precedence: an explicit ``FAMILIAR_ENABLE_WRITES`` wins. With it unset,
writes are **disabled by default in HTTP mode** (see :func:`set_http_context`)
and **enabled by default** otherwise (the local stdio case), unless the user
opted out with the disable-flag file (`familiar writes disable`).
"""
env = os.environ.get("FAMILIAR_ENABLE_WRITES")
if env is not None:
return env.strip().lower() in {"1", "true", "yes", "on"}
if _http_context:
return False
return not _flag_file().exists()
Generated
+1 -1
View File
@@ -439,7 +439,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=42.0.0" },
{ name = "fastmcp", specifier = ">=2.3.0" },
{ name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.27.0" },
]