Recipe — Unified self-contained graph-os (both MCP transports)¶
The unified-binary program's W-D workstream: one
graph-osprocess that IS everything — MCP tool surface + the engine authority — with no separate engine service to deploy, served over either MCP transport. Today the engine is a supervised local child process (epistemic-graph-server, packaged with the wheel); the in-process PyO3 embedding (W-A of the unified-binary program) is the same self-contained shape with the socket round-trip removed later, with no change to anything on this page — the config surface below is transport- and embedding-shape-agnostic. Concepts:CONCEPT:AU-OS.deployment.engine-resolver-auto-provision(the one resolver every entrypoint shares),CONCEPT:AU-P0-1(session currency). Companion pages: Tiny recipe (stdio only), Supported Configurations ladder, Configuration Reference §A.1 Engine resolution.
TL;DR — one config surface, two --transport values¶
Self-contained vs. remote is decided ONLY by GRAPH_SERVICE_ENDPOINTS. The
--transport flag (stdio vs. streamable-http) never affects this decision —
it only picks how graph-os itself is reached and which process-identity
bootstrap it uses. Verified this session by reading mcp_server()
(agent_utilities/mcp/kg_server.py) end to end: it calls
_get_engine() → create_backend() → GraphComputeEngine.get_or_create() →
engine_resolver.resolve_engine() identically for both transports, inside the
same with use_actor(...), use_session(...): block, with no transport-conditional
branch anywhere in between. A dedicated test proves this:
tests/unit/mcp/test_graphos_bootstrap_isolation.py::test_mcp_server_selects_local_engine_path_for_both_transports.
| stdio (packaged-local / CLI-embedded) | streamable-http (served / one self-contained pod) | |
|---|---|---|
| Invocation | graph-os --transport stdio |
graph-os --transport streamable-http --host 0.0.0.0 --port 8004 |
| Engine locality | Local iff GRAPH_SERVICE_ENDPOINTS is unset (same rule as the other column) |
Local iff GRAPH_SERVICE_ENDPOINTS is unset (same rule as the other column) |
| Process identity | May use the zero-config in-memory bootstrap (DEPLOYMENT_PROFILE=tiny, no endpoints, no external identity source) |
Always an external source: KG_AUTH_TOKEN_REF or KG_IDENTITY_OAUTH2 (the tiny bootstrap is stdio-only) |
| Inbound MCP auth | N/A (no separate clients; stdio has no request-level Authorization header) | --auth-type/AUTH_TYPE must resolve to a real scheme (static is the simplest self-contained choice — no external IdP) |
| Typical shape | One agent tool (Claude Code, opencode, IDE) spawns its own instance | One pod behind a Service/Ingress; scale by adding more self-contained pods (see Scaling caveat) |
Exact config for the unified/self-contained path¶
Both invocations below share the SAME engine-locality config. Only the identity block differs (stdio can use the zero-config bootstrap; streamable-http cannot).
// ~/.config/agent-utilities/config.json (or AGENT_UTILITIES_CONFIG_DIR)
{
// Absence — not an empty list, not an empty string — is what selects the
// local engine. If this key is present in config.json AT ALL (even null/"")
// it still participates in config-projection each load; DELETE the key.
// "GRAPH_SERVICE_ENDPOINTS": "...", // <- must not be present
"DEPLOYMENT_PROFILE": "tiny", // required for stdio's zero-config identity bootstrap;
// irrelevant to engine locality itself (that's GRAPH_SERVICE_ENDPOINTS alone)
"GRAPH_SERVICE_PERSIST_DIR": "/var/lib/agent-utilities/engine", // pin the durable snapshot dir (optional; defaults to the XDG data dir)
"ENGINE_LIFECYCLE": "refcounted", // or "persistent" for a served pod that should stay warm
"ENGINE_IDLE_SHUTDOWN_SECS": 60
}
⚠️ Config-precedence trap (validated this session).
config.jsonis re-projected into the process environment on every config load (core/config.py_commit_xdg_environment_projection,_load_xdg_json_config_locked), and it fills any key that ISN'T already an explicit process env var. If a prior setup ever wrotegraph_service_endpointsintoconfig.json(e.g. this host previously pointed at a remote/shared engine), removing or blanking theGRAPH_SERVICE_ENDPOINTSenvironment variable is not enough — an absent env var doesn't block the config.json projection, so the old remote endpoint keeps winning. The operator must remove the key fromconfig.jsonitself (setup-config/graph_configure action=set/ hand-editing the file) to actually reach the local-engine path.⛔ Do not carry over a topology-mode/endpoint/autostart key from an older release's
.envorconfig.json. The three separate keys that decision used to take are retired durable configuration keys — seecore/config.py_RETIRED_CONFIGURATION_KEYSfor the exact names — and if any is still present inconfig.jsonor the environment it raisesValueErrorand hard-fails boot before graph-os does anything else.GRAPH_SERVICE_ENDPOINTSunset/set is the entire, current mechanism.
stdio — packaged-local, CLI-embedded¶
// mcp_config.json (Claude Code / any MCP-aware IDE)
{
"mcpServers": {
"graph-os": {
"command": "graph-os",
"args": ["--transport", "stdio"]
}
}
}
With DEPLOYMENT_PROFILE=tiny, GRAPH_SERVICE_ENDPOINTS unset, and neither
KG_AUTH_TOKEN_REF nor KG_IDENTITY_OAUTH2 set, _mint_process_session("stdio")
takes the zero-config leg (local_process_authority_enabled() →
mint_local_process_session()): an asymmetric key signs a short-lived JWT
in-memory as a one-time proof, the key and token are destroyed immediately, and
the resulting process-lifetime GraphSession carries no user, host, endpoint, or
proof material. This is the sole local bootstrap in the whole codebase — it
requires stdio, tiny, and both identity refs unset simultaneously; any one of
those conditions failing falls through to the external-identity path below, never
silently.
streamable-http — served, one self-contained pod¶
graph-os --transport streamable-http --host 0.0.0.0 --port 8004 \
--auth-type static --static-tokens-ref env://GRAPHOS_STATIC_TOKENS
streamable-http is a SERVED_TRANSPORT (security/request_identity.py), so TWO
identity requirements apply that stdio's zero-config path never touches — neither
one is about engine locality; both are required even though the engine stays
local:
- The process's own bootstrap session —
_mint_process_session()never takes the local leg for a network transport (transport == "stdio"is a hard AND condition, not just profile-gated). Configure exactly one ofKG_AUTH_TOKEN_REF(a secret reference resolving to a provisioned JWT) orKG_IDENTITY_OAUTH2(a client-credentials block). For a genuinely zero-external-IdP pod, the simplest choice is a self-issued static token stored in the operator's secret backend and referenced byKG_AUTH_TOKEN_REF— no Keycloak/OIDC required. - Inbound per-request identity —
apply_served_security_profile()refuses to start unless--auth-type/AUTH_TYPEresolves to a real scheme (staticis the simplest self-contained option:--static-tokens-refnames a secret holding a{token: {client_id, scopes}}JSON map;jwt/oidc-proxy/oauth-proxy/remote-oauthare for an external IdP).AUTH_JWT_AUDIENCE(orMCP_JWT_AUDIENCE) andKG_POLICY_VERSIONmust also be set. Full detail: Secured single node (rung b), identity-jwt example.
Everything else — GRAPH_SERVICE_ENDPOINTS unset, GRAPH_SERVICE_PERSIST_DIR,
ENGINE_LIFECYCLE — is identical to the stdio column above. DEPLOYMENT_PROFILE
does not have to be tiny here (it only gates stdio's zero-config identity path);
single-node-prod is equally valid for a served self-contained pod. If the operator do set
DEPLOYMENT_PROFILE to anything other than tiny (or APP_PROFILE=production),
also set EPISTEMIC_GRAPH_ENCRYPTION_KEY_REF — _resolve_engine_encryption_key()
requires an explicit reference outside the tiny/dev combination rather than
silently persisting a local key file.
The engine auth EPISTEMIC_GRAPH_* inheritance¶
Whichever transport is serving, when graph-os autostarts its local engine child
(GraphComputeEngine._autostart_engine) it does NOT blindly forward its own
environment. _engine_child_environment() passes through only OS essentials, proxy
settings, and anything already prefixed EG_/EPISTEMIC_GRAPH_/GRAPH_SERVICE_/
LC_ (stripping the raw encryption key/ref and the auth secret, which are handled
explicitly below) — so unrelated provider API keys never reach the Rust child.
_autostart_engine() then:
- Derives
EPISTEMIC_GRAPH_AUDIENCE,EPISTEMIC_GRAPH_TENANT, andEPISTEMIC_GRAPH_POLICY_VERSIONfrom the verified processGraphSession'sengine_verified_context()— the SAME session_mint_process_session()produced for whichever transport is running. If any of these three happens to already be set in the passed-through environment and disagrees with the session, boot fails loud ("local engine authority policy does not match the verified process") rather than silently picking one. - Reuses or mints
EPISTEMIC_GRAPH_SIGNER_KEYS_JSON: if already present (passed through) it must contain a ≥32-byte key for the CURRENT verified actor or boot fails closed; if absent, a per-install signer key is generated once (_load_or_create_engine_bootstrap_signer_key()) and injected for this one bootstrap. - Sets
GRAPH_SERVICE_AUTH_SECRETtoresolve_engine_auth(config)— the configured secret, or a per-install HMAC secret auto-generated atdata_dir()/engine_secret(mode0600) the first time no secret is configured. - Sets
EPISTEMIC_GRAPH_ENCRYPTION_KEY(never_REF) — resolved once, at spawn time, fromEPISTEMIC_GRAPH_ENCRYPTION_KEY_REFif configured, else (tiny + non-production only) an auto-generated local private key. The raw key never round-trips throughconfig.json.
None of this differs by --transport — the child sees the exact same
EPISTEMIC_GRAPH_* projection whether its parent is a stdio or a streamable-http
graph-os, because both mint their bootstrap session through the same
_mint_process_session() chokepoint before _autostart_engine() ever runs.
Scaling caveat: each pod is independent¶
The unified-binary program's target is "the image runs one graph-os per pod;
multiple pods for scale." For the self-contained shape (this page), that is
literally true and nothing more: each pod's graph-os autostarts (or shares, if
already running in that pod) its OWN local engine with its OWN
GRAPH_SERVICE_PERSIST_DIR. Two self-contained pods do not share a knowledge
graph — there is no coordination between them. If the operator need N replicas serving the
same graph, that is the horizontal-scale-out shape: point every replica's
GRAPH_SERVICE_ENDPOINTS at one shared out-of-process engine instead (see
Split-storage engine / Enterprise) —
GRAPH_SERVICE_ENDPOINTS is still the ONLY switch between the two shapes.
Verify¶
# Zero-config identity boundary (stdio only)
agent-utilities-doctor --only graph_identity auth
# Confirm the resolved engine target for THIS process/profile:
agent-utilities-doctor --preflight --profile tiny
# stdio, ad hoc:
graph-os --transport stdio &
# streamable-http, ad hoc:
graph-os --transport streamable-http --host 127.0.0.1 --port 8004 \
--auth-type static --static-tokens-ref env://GRAPHOS_STATIC_TOKENS &
curl -s localhost:8004/health # liveness always 200; body reports real engine reachability
curl -s localhost:8004/health/ready # 200/503 mapped onto the SAME report
/health and /health/ready both call the shared
observability.runtime_health.collect_health_async() adapter and the same
collect_health() core, so they report the SAME truthful engine-reachability
detail the REST gateway's /health uses. The async adapter owns a reserved
two-thread control lane: probes never queue behind model, orchestration,
connector, or backup work in asyncio's general executor. A self-contained pod
that failed to bring its local engine up therefore shows unhealthy in the body
while liveness itself remains responsive (kubelet must not crash-loop the pod
for a dependency a restart cannot fix; /health/ready is what should gate
Service routing).
Wiring proof (this session)¶
GraphComputeEngine.get_or_create() is reached from mcp_server() through
_start_engine_bootstrap() → _get_engine() → create_backend() (always the
operational EpistemicGraphBackend() — no branch on transport) →
GraphComputeEngine.__init__ → engine_resolver.resolve_engine(config, graph_name),
which reads only config.graph_service_endpoints — never args.transport. A new
test exercises the real resolver from inside mcp_server()'s actual control flow
(only the deepest socket connect is mocked) and asserts a non-"remote"
resolution for both --transport stdio and --transport streamable-http when
GRAPH_SERVICE_ENDPOINTS is unset: