fastmcp-pvl-core
Shared FastMCP infrastructure: auth, middleware, logging, server-factory helpers
Ask AI about fastmcp-pvl-core
Powered by Claude Β· Grounded in docs
I know everything about fastmcp-pvl-core. Ask me about installation, configuration, usage, or troubleshooting.
0/500
Reviews
Documentation
fastmcp-pvl-core
Shared FastMCP infrastructure for the pvliesdonk/*-mcp server family:
auth, middleware, logging, config helpers, server-factory building blocks.
Ecosystem
fastmcp-server-templateβ copier template that scaffolds new FastMCP servers on top of this library.- Active consumers:
markdown-vault-mcp,scholar-mcp,image-generation-mcp. - Public API changes here propagate to consumers via periodic
copier updateruns against the template. - See the template's README for the update flow and the expected project shape.
API stability
This package is stable at 2.x and follows
semantic versioning: breaking changes bump the
major version, new features bump the minor, bugfixes bump the patch.
"Public API" means symbols re-exported from the top-level
fastmcp_pvl_core package (see __all__), which intentionally
covers both the runtime surface (auth, middleware, factory builders,
env/config helpers) and the CLI parser helpers consumed by downstream
server.py entrypoints. Modules prefixed with _ are internal and
may change without a major-version bump.
Install
uv add fastmcp-pvl-core
# If you use RemoteAuthProvider mode:
uv add "fastmcp-pvl-core[remote-auth]"
# For attaching a remote Python debugger inside a container image:
uv add "fastmcp-pvl-core[debug]"
Usage
See src/fastmcp_pvl_core/ for the full surface. Typical usage:
from fastmcp import FastMCP
from fastmcp_pvl_core import (
ServerConfig, build_auth, build_instructions,
wire_middleware_stack, env,
)
config = ServerConfig.from_env("MY_APP")
mcp = FastMCP(
name="my-app",
instructions=build_instructions(read_only=False, env_prefix="MY_APP", domain_line="β¦"),
auth=build_auth(config),
)
wire_middleware_stack(mcp)
Per-user subject mapping (bearer auth)
Bearer auth has two modes:
-
Single token β
MY_APP_BEARER_TOKEN=<token>accepts one shared token. Authenticated callers all share the same subject (default"bearer-anon"; override withMY_APP_BEARER_DEFAULT_SUBJECT=<value>). -
Mapped tokens β
MY_APP_BEARER_TOKENS_FILE=/path/to/tokens.tomlloads a tokenβsubject map at startup. Each token resolves to a distinct subject string for downstream attribution (audit logs, ACLs, request metadata).
# tokens.toml
[tokens]
"ghp_alice_xxxxxxxx" = "user:alice@example.com"
"sk_ci_yyyyyyyy" = "service:ci-bot"
If both MY_APP_BEARER_TOKEN and MY_APP_BEARER_TOKENS_FILE are set,
the file wins and a WARNING is logged. Subject strings are opaque to
the library; the <kind>:<id> convention (user:, service:,
token:) is documentation only.
If MY_APP_BEARER_TOKENS_FILE is set but the file is missing,
unparseable, or schema-invalid, the loader raises
fastmcp_pvl_core.ConfigurationError at startup β the server fails
fast rather than silently denying every request. The exception type
is part of the public API; downstream code can import and except
it as a stable contract.
MY_APP_BEARER_DEFAULT_SUBJECT only applies when bearer auth runs in
single-token mode (either standalone or as the bearer side of multi
mode alongside OIDC). It is ignored when MY_APP_BEARER_TOKENS_FILE
is set, including in multi mode β mapped mode uses the per-token
subjects from the TOML file.
Identifying the caller β get_subject
Tools, middleware, and resource handlers can call
fastmcp_pvl_core.get_subject() to retrieve the subject of the current
request without knowing which auth mode is active:
from fastmcp_pvl_core import get_subject
@mcp.tool
def whoami() -> str:
subject = get_subject()
return subject or "anonymous"
Resolution order:
- Token present: prefer
claims["sub"](OIDC's standard subject claim); fall back toclient_idifsubis absent. The auth builders normaliseclient_idper mode:bearer-singleβbearer_default_subject(default"bearer-anon").bearer-mappedβ the per-token subject from the TOML map.- OIDC modes (
oidc-proxy,remote) β typicallyclaims["sub"]wins (a real OIDC token always carriessub); theclient_idfallback is defensive. multiβ bearer-validated requests follow the bearer path, OIDC-validated requests follow the OIDC path.
- No token,
auth_mode == "none": returns the literal"local". - No token, auth required: returns
Noneβ caller decides whether to fall back or error.
Authorization (opt-in) β AuthorizationMiddleware
Tools, resources, and prompts can opt into per-subject access control by
setting meta={"required_scope": "<scope>"} at registration. A
configured AuthorizationMiddleware enforces the static check and
filters list_* responses to what the caller can use:
from pathlib import Path
from fastmcp_pvl_core import (
AuthorizationMiddleware, load_acl, make_acl_authorizer, check_authorization,
)
authorizer = make_acl_authorizer(load_acl(Path("/etc/my-app/acl.toml")))
mcp.add_middleware(AuthorizationMiddleware(authorizer=authorizer))
@mcp.tool(meta={"required_scope": "write"})
async def edit_document(project_id: str, doc_id: str, body: str) -> None:
# Coarse "write" gate already passed at middleware. Per-project gate here:
check_authorization(f"write:{project_id}")
...
ACL TOML schema (loaded by load_acl):
[subjects]
"user:alice@example.com" = ["read", "write"]
"user:admin@example.com" = ["*"] # wildcard scope
"service:ci-bot" = ["read"]
"local" = ["*"] # stdio mode
Key properties:
- Opt-in per component. Tools / resources / prompts without
meta["required_scope"]are unrestricted regardless of caller. *is the only library-treated special scope ("any required scope passes"). All other scopes are opaque strings; downstream chooses the vocabulary.- Subject-side wildcards (
*as an ACL key) are rejected at load time. load_aclfails fast withConfigurationErroron every malformed condition β never silent denial.- ACL is loaded once at startup. Restart to pick up changes.
- Authorization scopes are application-level and distinct from the OAuth scopes carried in tokens.
- Subject is logged on every deny at WARNING. The wire-side payload
omits the subject by default to limit cross-user info disclosure;
pass
AuthorizationMiddleware(..., expose_subject_in_error=True)to include it (e.g. for internal-only servers).
For the full design rationale and deviations from the originating
issue, see
docs/specs/authorization-submodule.md.
Remote debugging in containers
Containerised consumers can opt into a remote Python debugger by calling
maybe_start_debugpy(env_prefix) early in their CLI entrypoint, passing
the same per-app prefix the server uses for the rest of its config:
from fastmcp_pvl_core import configure_logging_from_env, maybe_start_debugpy
def main() -> None:
configure_logging_from_env()
maybe_start_debugpy("MY_APP") # no-op unless MY_APP_DEBUG_PORT is set
...
Environment contract ({PREFIX} matches the argument):
{PREFIX}_DEBUG_PORTβ TCP port to listen on. Unset, blank, or any value that parses to0is a silent no-op. Non-numeric or out-of-1..65535values log aWARNINGand the helper returns without raising.{PREFIX}_DEBUG_WAITβ when truthy (1/true/yes/on, case-insensitive), block startup until the IDE attaches. Default is non-blocking.- If
debugpy.listen()itself fails (port in use, permission denied, debugpy-internal error), the helper logs aWARNINGand continues β a debug-port problem must never crash the server.
Install the optional debug extra on images that need the listener:
uv add "fastmcp-pvl-core[debug]" # quote brackets in zsh
# or, equivalently:
uv add debugpy
The helper logs a WARNING and continues if debugpy is unavailable,
so it is safe to ship in default scaffolds.
β οΈ Security: the listener binds
0.0.0.0and debugpy's DAP protocol is unauthenticated β any peer that can reach the port has arbitrary code execution as the server process. Only enable{PREFIX}_DEBUG_PORTin environments where the port is reachable solely from a trusted developer workstation, e.g.kubectl port-forward,docker run -p 127.0.0.1:5678:5678(loopback bind), or an SSH tunnel. Never publish the debug port on a public network.
License
MIT
