From 458e31685f8ff12c6192c25038802b3f9ec95356 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 18:20:59 +0200 Subject: [PATCH] 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. --- pyproject.toml | 2 +- src/familiar/__main__.py | 15 ++++++++++- src/familiar/auth.py | 47 ++++++++++++++++++++++++++++++++++ src/familiar/config.py | 21 ++++++++++++++++ src/familiar/server.py | 54 ++++++++++++++++++++++++++++++++++++++++ src/familiar/writes.py | 26 ++++++++++++++++--- uv.lock | 2 +- 7 files changed, 160 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6b1974..5e34e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/familiar/__main__.py b/src/familiar/__main__.py index cd3158a..56caf25 100644 --- a/src/familiar/__main__.py +++ b/src/familiar/__main__.py @@ -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, diff --git a/src/familiar/auth.py b/src/familiar/auth.py index 4d315e8..ae95cb4 100644 --- a/src/familiar/auth.py +++ b/src/familiar/auth.py @@ -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() diff --git a/src/familiar/config.py b/src/familiar/config.py index 6d02dc1..7858861 100644 --- a/src/familiar/config.py +++ b/src/familiar/config.py @@ -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.""" diff --git a/src/familiar/server.py b/src/familiar/server.py index 18a1b72..8c52747 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -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 `` 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, + ) diff --git a/src/familiar/writes.py b/src/familiar/writes.py index 2efada6..74e9dbc 100644 --- a/src/familiar/writes.py +++ b/src/familiar/writes.py @@ -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() diff --git a/uv.lock b/uv.lock index ff58b1a..eb94ac5 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ]