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:
7 files changed
+160
-7
No files matched your search
+1
-1
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user