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
7 changed files with 160 additions and 7 deletions
Showing only changes of commit 458e31685f - Show all commits

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastmcp>=2.3.0", "fastmcp>=3.0.0", # streamable-HTTP transport + StaticTokenVerifier bearer auth
"httpx>=0.27.0", "httpx>=0.27.0",
"cryptography>=42.0.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 Claude Code / Cowork launch it). The auth subcommands are meant to be run by
hand in a terminal: 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 # 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 status # list logged-in characters
familiar logout # forget ALL characters familiar logout # forget ALL characters
familiar logout NAME # forget one character (by name or id) familiar logout NAME # forget one character (by name or id)
@@ -35,13 +38,22 @@ def _human_size(num: int) -> str:
def _login() -> int: 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')}).") print(f"Added {bundle.get('character_name')} (id {bundle.get('character_id')}).")
chars = auth.list_characters() chars = auth.list_characters()
print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}") print(f"Logged-in characters ({len(chars)}): {', '.join(c['character_name'] for c in chars)}")
return 0 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: def _status() -> int:
chars = auth.list_characters() chars = auth.list_characters()
if not chars: if not chars:
@@ -101,6 +113,7 @@ def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else None command = sys.argv[1] if len(sys.argv) > 1 else None
handlers = { handlers = {
"login": _login, "login": _login,
"serve": _serve,
"status": _status, "status": _status,
"logout": _logout, "logout": _logout,
"writes": _writes, "writes": _writes,
+47
View File
@@ -16,6 +16,7 @@ import os
import secrets import secrets
import time import time
import webbrowser import webbrowser
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse from urllib.parse import parse_qs, urlencode, urlparse
@@ -328,6 +329,52 @@ def interactive_login(*, open_browser: bool = True) -> dict[str, Any]:
return bundle 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]: def status() -> dict[str, Any]:
"""Report which characters are logged in (no network call).""" """Report which characters are logged in (no network call)."""
chars = list_characters() chars = list_characters()
+21
View File
@@ -111,6 +111,27 @@ WRITE_SCOPES = [
SCOPES = os.environ.get("FAMILIAR_SCOPES", " ".join(READ_SCOPES + WRITE_SCOPES)).split() 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: 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."""
+54
View File
@@ -15,6 +15,7 @@ from . import (
__version__, __version__,
account, account,
comms, comms,
config,
contacts, contacts,
corp, corp,
corp_economy, corp_economy,
@@ -107,3 +108,56 @@ ui.register(mcp)
# Phase 4: planetary interaction (read-only). # Phase 4: planetary interaction (read-only).
planets.register(mcp) 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" return config.data_dir() / "writes_disabled"
def writes_enabled() -> bool: # Flipped on by `familiar serve` (HTTP transport). It only changes what the
"""True if writes are allowed (env override or the on-disk toggle). # *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") env = os.environ.get("FAMILIAR_ENABLE_WRITES")
if env is not None: if env is not None:
return env.strip().lower() in {"1", "true", "yes", "on"} return env.strip().lower() in {"1", "true", "yes", "on"}
if _http_context:
return False
return not _flag_file().exists() return not _flag_file().exists()
Generated
+1 -1
View File
@@ -439,7 +439,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cryptography", specifier = ">=42.0.0" }, { name = "cryptography", specifier = ">=42.0.0" },
{ name = "fastmcp", specifier = ">=2.3.0" }, { name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
] ]