feat: serve familiar as a remote HTTP MCP server (bearer auth) #63
No files matched your search
+1
-1
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user