Applications
The mountable application classes: OpenAPI, MCP, and the automatic _server
application with its sections.
OpenAPI
OpenApiApplication: a RoutedApplication that exposes REST + OpenAPI + docs.
OpenApiApplication wraps an API surface — either the app’s own
@route methods (direct mode) or an external RoutingClass attached
under api_name (mounted mode) — and adds a _meta sub-tree with three
introspection endpoints:
_meta/schema_json— the OpenAPI 3.1 document of the API;_meta/docs— a Swagger-UI page pointing at_meta/schema_json;_meta/index— an HTML splash linking to the docs.
The schema is built STANDALONE from the app’s own router
(router_openapi(app.route)): there is no dependency on a _server
application (that surface belongs to a later macro). The mounted routing
class is linked as an eager instance branch, so it inherits the app
router’s plugins — pydantic among them — and its handler signatures are
captured into the neutral params/result blocks the
OpenAPITranslator reads; in direct mode the same plugins reach the app’s
own router through the server’s plugin arming (PluginMixin config).
The docs and splash HTML live in dedicated resource files next to this module and are read at USE time (never at import): a swap of the template file takes effect without re-importing the package.
Kwargs peeled by the cooperative __init__ (D16): routing_class (a
RoutingClass instance to mount), module ("pkg.mod:ClassName"
import path, an alternative to routing_class), docs (documentation
style — "swagger" or "off") and api_name (the segment the mounted
class is attached under, default "api"). The rest flows down the chain
(db_name to RoutedApplication, code/mount to
BaseApplication).
- class genro_asgi.applications.openapi.OpenApiApplication(**kwargs)[source]
Bases:
RoutedApplicationExpose an API surface as REST + OpenAPI 3.1 with a Swagger docs page.
Two ways to supply the API:
direct mode — subclass and write
@routemethods on the app itself; endpoints sit at the app root (/{app}/endpoint);mounted mode — pass
routing_class=(ormodule=); the class is attached underapi_name(/{app}/{api_name}/endpoint).
Meta endpoints are attached under
_metain both modes.- Parameters:
kwargs (Any)
- property api_info: dict[str, Any]
the mounted class’s, else the app’s, else empty.
- Type:
OpenAPI info dict
MCP
MCP applications: the stateless Streamable HTTP transport over McpEngine.
Two ready-to-mount apps expose a genro-routes router as MCP tools over JSON-RPC
2.0. Both delegate the HTTP shell to a McpTransport — the helper that
owns everything the transport-agnostic McpEngine does not:
method/header/Origin gating, the JSON-RPC envelope, the 202-for-notifications
rule, and the sync/async invoke callback (async handlers stay on the loop, sync
handlers go through the server pool via run_sync — the Macro 1 protocol,
replacing the old smartasync). The transport holds its owning application as
self.application (dual-parent) and reaches its spread_over_params and
server through it.
McpApplication— the whole app is one MCP endpoint. It holds an engine over an EXTERNAL router (routing_class=ormodule=); every request is a JSON-RPC message. Without a routerinitializestill answers andtools/listis empty.McpOpenApiApplication— one router, two faces: it inherits the whole OpenApiApplication machinery (_metadocs, pydantic plug, REST dispatch) and adds an MCP face undermcp_name_segment(default"mcp"). Thechannelplugin drives per-face visibility: a method is an MCP tool only on channel"mcp"(@route(channel_channels="mcp,rest")for a dual method); undeclared methods default to REST-only (channels=rest_channel).
Transport conformance (MCP Streamable HTTP): a JSON-RPC POST answers with a
JSON response; a notification (no id) answers HTTP 202 with an empty body; a
GET opens the SSE push stream (below); any other method answers 405; an
MCP-Protocol-Version header that is present but unsupported answers 400 (an
absent header is assumed 2025-03-26 per the spec’s backwards-compat rule);
an Origin header present and not in allowed_origins answers 403 (the
allowed_origins option defaults to None — no restriction, a dev-mode
default: production fronting owns the Origin gate). The transport gates raise
core HTTP exceptions answered by the server’s ErrorMiddleware.
The push half (core 1e, the option-B commitment honored): a GET opens a
text/event-stream keyed by Mcp-Session-Id — echoed when the client
supplies one, minted otherwise (secrets.token_urlsafe, decoupled from the
cookie session: MCP clients carry no cookie) — and follows the server’s task
hub live (server.tasks.hub, the A<->C bridge). A Last-Event-ID header
replays the session’s current progress.json snapshots first
(snapshot-baseline resumability — no durable event log, ratified). A server
composed without tasks answers GET with 405, the 1c stateless behavior. The
engine stays untouched apart from advertising the capability.
- class genro_asgi.applications.mcp.McpApplication(**kwargs)[source]
Bases:
RoutedApplicationStandalone MCP transport: the whole app is one JSON-RPC endpoint.
Supply the tool surface as an external
RoutingClass(routing_class=instance, ormodule="pkg.mod:Class"imported and instantiated with no arguments); every route on it becomes a tool. Thechannelfilter only bites when the external router plugs thechannelplugin, so a plain router exposes all its routes. Without a router the app still answersinitializeand lists no tools. Class attributes carry the MCP identity defaults so a subclass can set them declaratively.- Parameters:
kwargs (Any)
- class genro_asgi.applications.mcp.McpOpenApiApplication(**kwargs)[source]
Bases:
OpenApiApplicationOpenApiApplication that also exposes its API router as MCP tools.
The REST/OpenAPI faces work exactly as in
OpenApiApplication; the MCP face answers undermcp_name_segment(default"mcp") via the same engine, pointed at the API router (the app’s own in direct mode, the mounted class’s in mounted mode). Visibility is thechannelplugin’s job: the MCP face lists only channel-"mcp"entries; undeclared methods default to REST-only (channels=rest_channel).- Parameters:
kwargs (Any)
- property mcp_engine: McpEngine | None
The MCP engine driving this app’s tool surface (
Noneif unbuilt).
- auth_filters(scope)[source]
Node-resolution filters: the base auth tags plus the REST channel.
The API router carries the
channelplugin (a method is an MCP tool only on channel"mcp"), so the REST face must resolve on the REST channel; the MCP face passes"mcp"through the engine. The filter is harmless when no router on the path plugschannel(it is ignored).
McpEngine — MCP (JSON-RPC 2.0) core over a genro-routes Router.
The engine turns a Router’s @route entries into MCP tools and serves the
protocol methods. It is transport- and app-agnostic: it holds a Router and a
channel to filter on and never touches HTTP concerns (headers, Origin,
202-for-notifications belong to the host application). dispatch receives
the parsed JSON-RPC message, validates the envelope, and resolves method
on a genro-routes tree of its own — McpDispatcher, held as
mcp_dispatcher — the same machinery the lane and the HTTP side use: no
chain of if on the method name. A method nobody serves reads
node.error (the stable genro-routes contract — resolution never raises)
and becomes -32601 THERE, in one place. What dispatch returns is the
RESULT object — envelope bookkeeping (id, jsonrpc) stays with the
transport; protocol failures raise McpError carrying the JSON-RPC
code for the transport to render. A list payload is rejected with -32600:
JSON-RPC batching entered the MCP spec in 2025-03-26 and was removed in
2025-06-18.
The tree (protocol 2025-11-25, the current revision), every route taking the
protocol signature (params, auth_tags):
pinganswers an empty result (spec MUST).initializenegotiates the version: the client’s requested version is echoed when it appears inSUPPORTED_VERSIONS, anything else is answered with the latest supported revision.toolsis a branch,McpTools:listandcallwith everything that builds their answers. Each further family of the protocol (prompts,resources,server) is a branch of its own, a class of its own, attached the same way.
- class genro_asgi.mcp.engine.McpDispatcher(engine)[source]
Bases:
RoutingClassThe root of the methods the engine serves:
ping,initialize,tools/….The tree is the table:
route.nodes()lists what the engine answers without a message being dispatched. The branches are kept as attributes so a host can attach its own family beside them.- Parameters:
engine (
McpEngine) – the engine whose identity and versionsinitializeanswers.
- class genro_asgi.mcp.engine.McpEngine(router=None, *, name='genro-mcp', version='1.0.0', tool_separator='.', channel='mcp', invoke=None)[source]
Bases:
objectMCP JSON-RPC core over a router.
- Parameters:
router (
Router|None) – The genro-routes Router whose entries are exposed as tools.version (str) – server identity returned by
initialize.tool_separator (
str) – joins router/method segments into a flat tool name.channel (
str) – channel to filter entries on (visibility per channel).invoke (
Callable[[Any,dict],Any] |None) – callback(node, arguments) -> resultrunning a resolved node;tools/callawaits an awaitable result. Host applications pass their own to interpose parameter adaptation (e.g.spread_over_params) and pool dispatch for sync handlers; the default calls the node directly.name (str)
version
Server application
ServerApplication: the automatic _server system app (D4).
ServerApplication is the server’s own application — the system surface
every server exposes under /_server without configuring it (D4:
“automatic, not configured”). AsgiServer mounts one at the end of its
__init__ (_register_server_app), so a hand-built
AsgiServer(applications=[...]) gets it exactly like a configured one; no
configuration path special-cases it. The demux finds it through the ordinary
mount table — there is no dedicated demux logic.
It extends OpenApiApplication (REST + OpenAPI; the MCP face on
_server is out of this wave), so /_server/_meta/ carries the usual
schema/docs/index endpoints, and adds:
index— the/_server/descriptor: title and the attached section names (JSON — no HTML in code);sections/attach_section(section, name)— the registry of system sections:attach_sectionlinks aRoutingClassundername(endpoints at/_server/<name>/...) and records it so introspection surfaces (the index today, monitors later) can enumerate them;the PASSWORD login surface (core 1d wave 1):
login(JSON POST →UserStore.verify→Avatar→request.session.attach_avatar),login_page(HTML GET, the descriptor-drivenresources/login.htmlread at USE time),logoutand the publiclogin_methods— dual-mode by TWO routes, never in-handlerAcceptsniffing. The methods live in anAuthSectionattached underauth(ensure_auth_section/register_auth_method);PasswordMethodis registered at construction.loginenforces the store-backed lockout (REVIEW #9): the per-identity failure counter (failed_attempts/last_failed_at) rides the UserStore record with exponential backoff; the policy comes from the config’sauthentication.loginelement (login_policy, defaults 5 attempts / 30s base).
Handlers stay PURE: they return values and never touch cookies or an ambient
request/response (the old self.server.request idiom must never be
reintroduced). Login attaches the avatar to the existing session in place —
the id never changes, so no login-time cookie exists. A handler that needs the
live request DECLARES an UNANNOTATED _request parameter: bind_kwargs
injects the per-dispatch Request for that name — the same declarative
convention body_data follows — and the handler reaches the server through
_request.server. Leaving it unannotated keeps it out of the pydantic model
(and thus the public OpenAPI schema); the pydantic wrapper, seeing no type hint,
passes it straight through instead of routing it into validation. The _
prefix is the injected-name convention bind_kwargs matches in the neutral
fields block. pydantic and openapi are fixed server structure (armed
on every router by PluginMixin), so the handler signatures are always
captured and per-entry OpenAPI controls (openapi_method) always take effect.
The future internal server (a D8 orchestration concern) is a SUBCLASS that overrides what it needs — not a profile flag on this class: no code exists for a consumer that does not exist yet.
Identity: code and mount are both declared "_server" as class
attributes — the system mount is a D4 invariant, and three cross-file
references hardcode /_server/... (PasswordMethod’s action,
LOGIN_PAGE_URL, login.html’s fetch), so moving this app elsewhere
404s them.
Kwargs peeled by the cooperative __init__ (D16): login and oidc are
the login-surface values of the configuration’s authentication section (the
server_app= server kwarg, forwarded by _register_server_app): the lockout
policy dict and the per-code OIDC provider dicts, stored as
login_policy/oidc_providers (consumed by the lockout check and the
OidcMethod registration). The rest flows down the chain. A hand-built
AsgiServer(applications=[...]) passes nothing, so the defaults (empty dicts)
keep today’s bare app.
- class genro_asgi.applications.server_app.ServerApplication(**kwargs)[source]
Bases:
OpenApiApplicationSystem endpoints of a server, auto-mounted under
/_server(D4).Carries the public server’s system surface: the password login surface and the sections attached through
attach_section, listed by theindexdescriptor. The future internal server (a D8 orchestration concern) will be a SUBCLASS overriding what it needs — not a profile flag on this class.- Parameters:
kwargs (Any)
- property oidc_providers: dict[str, dict[str, Any]]
OIDC provider configs from the
oidc()elements, keyed by code.
- property sections: dict[str, RoutingClass]
Attached system sections keyed by their mount segment (may be empty).
- property auth_section: AuthSection | None
The
authsection carrying the login methods, orNone.
- attach_section(section, name)[source]
Attach
sectionundernameand record it insections.Links the section’s router into this app (endpoints at
/_server/<name>/...) and keeps it enumerable for the introspection surfaces (theindexdescriptor today).
- register_auth_method(method)[source]
Register a login method in the
authsection (created on demand).- Return type:
- Parameters:
method (AuthMethod)
- login(identity='', password='', _request=None)[source]
Authenticate against the server’s UserStore and attach the identity.
The JSON convergence point of every
formmethod: verifies the credentials (UserStore.verify— the record key isidentity), builds theAvatarand attaches it to the request’s session in place (_request.session.attach_avatar) — the session id never changes at login, so the client’s cookie stays valid and noSet-Cookieis involved. The server’suser_storeis wired in the next wave (Macro 5b): until then a server without one answers the error shape.The
nextreturn path is NOT a login parameter: the challenge redirects tologin_page?next=...and the page script owns the post-success redirect —loginitself never sees it and posts carry only the credentials.Enforces the server-side lockout (REVIEW #9): the failure counter lives ON the user’s store record (
failed_attempts/last_failed_at), so it survives restarts and is shared across processes on a shared store. Aftermax_attemptsconsecutive failures the identity is refused until the exponential-backoff window (_lock_seconds_remaining) has passed; refused attempts never touch the counter — an attacker hammering a locked identity cannot extend a legitimate user’s lock — and a success resets it. Known-identity failures surface the server-computedremaining_attempts; unknown identities have no record, hence no counter and no such field. Per-IP rate limiting is a future middleware concern, not this handler’s.The method is POST by declaration (
openapi_method="post"): with_requesthidden from the schema (see below) the remaining fields are all scalar, so the guesser would otherwise pick GET.- Parameters:
identity (
str) – The record key to verify (NOT the oldusername).password (
str) – The password to verify._request – The live
Request, injected bybind_kwargs. Left unannotated so it stays out of the pydantic model — and thus out of the public OpenAPI request body — while the_prefix is the injected-name conventionbind_kwargsmatches.
- Return type:
- Returns:
{session_id, identity, tags}on success;{"error": ...}on missing/invalid credentials, active lockout, or when no user store is wired — withremaining_attemptswhen the identity has a record.
Note
Route: POST /_server/login
- login_page(next='')[source]
Serve the descriptor-driven HTML login page (GET, dual-mode twin of
login).The page builds itself from
login_methodsand posts credentials to the method’saction(/_server/login). Read at USE time so a template swap needs no re-import.nextis accepted so the challenge redirect’s query binds; the page script consumes it client-side.Note
Route: GET /_server/login_page
- logout(session_id='')[source]
Destroy a session.
Deletes the session from the store. No error if the session is unknown.
- Parameters:
session_id (
str) – Session token to invalidate.- Return type:
- Returns:
{"status": "ok"}(always succeeds).
Note
Route: POST /_server/logout
- login_methods()[source]
Public descriptors of the active auth methods (NO
auth_rule).The login page builds itself from this: register a method, its descriptor (and therefore its button/form) appears. Deliberately public — a caller must see the methods before it can authenticate. Empty list when no login surface is active.
Note
Route: GET /_server/login_methods
Server sections
The _server/auth container: the mount that holds the auth-method sections.
When the login surface is active the ServerApplication attaches ONE
AuthSection under the auth name, so it lives at /_server/auth/.
A registered auth method (AuthMethod) is attached to this section under
its method_id ONLY when it owns routes, so those routes live at
/_server/auth/<method_id>/ (e.g. a future OIDC start and callback
at /_server/auth/oidc:google/start). A route-less method — the password
one — is recorded in the registry but never attached: zero-route nodes never
enter the routing tree (Invariant 10).
The section is a thin router node: it holds no routes of its own, it only
carries the routed method children and keeps the ordered registry the login
surface reads to build login_methods. Routing is dispatch; the registry
is this dict.
- class genro_asgi.applications.server_sections.auth_section.AuthSection(application)[source]
Bases:
RoutingClassThe
_server/authmount that carries the registered auth methods.Note
Parent (dual relationship): the ServerApplication, stored as
self.application. The AsgiServer is reached viaself.application.server.- Parameters:
application (ServerApplication)
- __init__(application)[source]
Bind the section to its ServerApplication and start an empty registry.
- Parameters:
application (
ServerApplication) – The ServerApplication this section belongs to (dual relationship). The AsgiServer isapplication.server.- Return type:
None
- property methods: dict[str, AuthMethod]
The registered methods, keyed by
method_id(insertion order).
- register(method)[source]
Record a method; mount its routes only when it owns some.
Every method enters the ordered registry the login surface reads to build
login_methods. Only a method that OWNS routes is also linked into this section’s router undermethod_id(so its routes live at/_server/auth/<method_id>/); a route-less method (the password one) stays registry-only — zero-route nodes are never attached to the routing tree (Invariant 10: routing is dispatch, never a registry).- Parameters:
method (
AuthMethod) – The AuthMethod to register. Itsmethod_idmust be unique.- Raises:
ValueError – If a method with the same
method_idis already registered (method ids are unique by contract, so a clash is a configuration bug).- Return type:
The _server/users section: SUPERADMIN-gated user management.
UsersSection is a RoutingClass the ServerApplication attaches under
users (endpoints at /_server/users/...). Every route is gated
auth_rule="SUPERADMIN" — the section is ALWAYS declared (fixed structure,
D26), and each handler answers the {"error": ...} shape (coherent with
login) when the server has no user_store wired.
The credential invariant: password_hash NEVER crosses the wire. list
and get strip it from the record; save never accepts it (it merges the
non-credential fields of the body over the stored record, preserving the hash);
a password enters the system only as plaintext through create_user and
set_password, hashed server-side via UserStore.hash_password.
Route responsibilities are separated:
create_user— births a NEW record (error if the identity exists): the body carriespassword/password_confirm(the server checks they match) plus the metadata andtags;save— updates an EXISTING record only (error if absent): merges the body’s metadata/tags over the stored record,password_hashuntouched. The body is taken whole (body_data) so tomorrow’s metadata fields need no signature change;set_password— changes the credential of an existing record only, with the samepassword/password_confirmserver-side check;delete— removes a record.
Parent (dual relationship): the ServerApplication, stored as
self.application; the store is reached via self.application.server.user_store.
- class genro_asgi.applications.server_sections.users_section.UsersSection(application)[source]
Bases:
RoutingClassThe
_server/usersmount: SUPERADMIN CRUD over the server’s UserStore.Note
Parent (dual relationship): the ServerApplication, stored as
self.application. The store isself.application.server.user_store.- Parameters:
application (ServerApplication)
- __init__(application)[source]
Bind the section to its ServerApplication (dual relationship).
- Parameters:
application (ServerApplication)
- Return type:
None
- property user_store: UserStore | None
The server’s UserStore, or
Nonewhen identity is unconfigured.
- matched_password(body)[source]
The password when it is present and matches its confirmation, else None.
The caller distinguishes a mismatch (this returns None) from an absent password by checking the body itself: only
create_userrequires one.
- create_user(identity='', body_data=None)[source]
Create a NEW user from the body (metadata + tags + password/confirm).
Errors if the identity already exists. The password is validated against its confirmation and hashed server-side;
password_hashnever arrives pre-formed. Metadata andtagsfrom the body land on the new record. A new record defaults toenabled: Trueandtags: [](creating a user with a password means letting them log in — the body can still sayenabled: falseto create it disabled);verifyrequiresenabledand the loginAvatarrequirestags, so a minimal body must still produce a working user.
- save(identity='', body_data=None)[source]
Update an EXISTING record’s metadata/tags;
password_hashuntouched.Errors if the user does not exist (creation is
create_user’s job). The body is merged whole over the stored record — new metadata fields persist with no signature change — but the credential never moves here.
- set_password(identity='', body_data=None)[source]
Change an existing user’s password (plaintext in, hashed server-side).
The body carries
password/password_confirm; the server checks they match. Errors if the user does not exist.
The _server/tokens section: SUPERADMIN-gated issued credentials.
TokensSection is the ONE section for credentials the server issues: API
keys (gak_) and short-lived JWTs. It is a RoutingClass the
ServerApplication attaches under tokens (endpoints at
/_server/tokens/...), ALWAYS declared (fixed structure, D26). Every route is
auth_rule="SUPERADMIN" and answers the {"error": ...} shape when the
server has no api_key_store wired.
The secret invariant mirrors the users section: an api key’s secret_hash
NEVER crosses the wire (list strips it), and the full gak_ key is
returned ONLY by issue, once — it is never retrievable again (the record
keeps only its hash).
create_jwt mints a JWT signed with the FIRST symmetric (HS*) verifier in
the auth config (AuthCore.signing_jwt_config): the token verifies against
the same config, so no new key material is introduced. With no symmetric verifier
configured it answers the error shape.
Parent (dual relationship): the ServerApplication, stored as
self.application; the stores are reached via self.application.server.
- class genro_asgi.applications.server_sections.tokens_section.TokensSection(application)[source]
Bases:
RoutingClassThe
_server/tokensmount: SUPERADMIN api-key registry + JWT minting.Note
Parent (dual relationship): the ServerApplication, stored as
self.application. The stores live onself.application.server.- Parameters:
application (ServerApplication)
- __init__(application)[source]
Bind the section to its ServerApplication (dual relationship).
- Parameters:
application (ServerApplication)
- Return type:
None
- property api_key_store: ApiKeyStore | None
The server’s ApiKeyStore, or
Nonewhen tokens are unconfigured.
- issue(body_data=None)[source]
Mint an api key; return the full
gak_key ONCE (never again).Body:
label(required),tags(default[]),expires_at(POSIX timestamp or absent for a key that never expires).
- create_jwt(body_data=None)[source]
Mint a JWT signed with the first symmetric verifier of the auth config.
Body:
sub(required — the token’s subject),tags(default[]),expires_in(seconds; absent for noexpclaim). The token verifies against the same config it was signed with (no new key material). Answers the error shape when no symmetric verifier is configured.
The _server/tasks section: SUPERADMIN-gated task backbone endpoints.
A RoutingClass the ServerApplication attaches (attach_section), so
its routes live at /_server/tasks/.... JSON endpoints ONLY — no HTML/JS
panel ships with the core (ratified). Two surfaces over the server’s
TaskManager:
schedules (the recurring scheduler’s store):
list/create/update/enable/disable/run_now/delete/logs;spool (the batch folders):
spool_list(byownerorstatus),progress,cancel,result.
Every route is gated auth_rule="SUPERADMIN". A server composed without the
task backbone — or with tasks=False — answers every endpoint with the
{"error": ...} document (HTTP 200): the section is ALWAYS declared, fixed
structure (D26), the payload states the availability.
Parent (dual relationship): the section holds its ServerApplication as
self.application and reaches the manager via self.application.server.tasks
(guarded by tasks_enabled — the property raises when disabled).
- class genro_asgi.applications.server_sections.tasks_section.TasksSection(application)[source]
Bases:
RoutingClassThe
/_server/tasksendpoints over the server’s task backbone.Note
Bound to its
ServerApplication(dual relationship:self.application); the manager, store, scheduler and spool are reached throughself.application.server.tasks.- Parameters:
application (ServerApplication)
- __init__(application)[source]
Bind the section to its ServerApplication (dual relationship).
- Parameters:
application (ServerApplication)
- Return type:
None
- property manager: TaskManager | None
The server’s TaskManager, or
Nonewhen tasks are off/absent.
- create(body_data=None)[source]
Create a schedule:
code,kind,specrequired.task_namedefaults tocode;kwargsandenabledare optional.next_run_tsis computed here (an invalid spec is the{"error": ...}answer, not a record).
- update(body_data=None)[source]
Merge the editable fields into a schedule (
codenames it).A changed
kind/specrecomputesnext_run_ts; the run-outcome fields are the scheduler’s and never settable from the wire.
- spool_list(owner='', status='')[source]
Task descriptors by
ownerOR bystatus(one filter required).