MCP Runtime¶
Server shape¶
Entrabot is a stdio MCP server built on FastMCP. Every host-facing capability — Teams, email, Files, cards, promises, the daily summary, A365 Work IQ — is registered as an @mcp.tool() function in src/entrabot/mcp_server.py. Each tool wrapper does its work and returns a JSON string (json.dumps(...)), not a raw Python object, so every tool has the same wire shape regardless of what it does internally.
_run_stdio_with_write_stream() is the process entry point: it opens the stdio transport, stashes the write stream in module state (_state["_write_stream"]) so background tasks can push notifications outside of a tool call, discovers efferent-copy sinks, eagerly schedules _initialize(), and hands control to FastMCP's request loop.
Initialization lifecycle¶
_initialize() is eagerly scheduled by _run_stdio_with_write_stream() when the server starts, so auth, watched-chat loading, and background polling do not wait for the first tool call. Tool wrappers also call _initialize() as an idempotent fallback; once _state["initialized"] is set, later calls short-circuit.
_init_auth()— authenticates by credential presence, not byENTRABOT_MODE. When a Blueprint app ID + tenant ID are configured andENTRABOT_SKIP_PROVISIONINGis false, it tries the three-hop Agent User fast path first; on failure or bypass it falls back to MSAL delegated auth whenENTRABOT_CLIENT_IDis set, landing inUNAUTHENTICATEDif neither path succeeds.ENTRABOT_MODEis validated but not currently consumed here. See Identity and Token Flow for the flow itself and the state machine it drives.- Storage backend validation —
assert_backend_config()resolves and validates the configuredMemoryBackendonce at boot. A half-configured Blob environment raises here and aborts initialization rather than silently falling back toLocalBackend. This comes before poll setup, which reads through the backend. _init_poll()— loads the persistedwatched_chatslist through the backend in worker threads, and starts the Teams poll task if at least one chat is already watched. If the list can't be read, it starts with no chats, never falls back to a local copy, and retries every 60 seconds until the list loads. It starts email poll, chat auto-discovery, and the daily summary scheduler only when the resulting session is an Agent User session.- Active-sponsor-channel binding log — logs the configured TTL for the in-memory sponsor/chat binding table used to authorize mutating tools (
add_teams_member,share_file).
Every call also captures the connected client's clientInfo.name into _state["cached_host"] (_capture_host_from_context()), independent of whether this is the first call — background tasks that run outside a live request context read this cached value.
Background task matrix¶
| Task | Interval | Gate | Notes |
|---|---|---|---|
Teams chat poll (_background_poll) |
5s | At least one chat in watched_chats at boot or registered later; not gated on auth mode |
Iterates every watched chat each cycle; a single chat's failure is logged and doesn't stop the others in that cycle. |
Email poll (_background_poll_email) |
60s | Agent User mode only | Polls /me/messages — targets the agent's own mailbox, which only resolves correctly in Agent User mode. Each cycle reads the shared cursor and claims the window before pushing it. |
Chat auto-discovery (_background_discover_chats) |
120s | Agent User mode only | Sweeps every page of GET /me/chats (no $orderby — it 400s there), up to 20 pages, and registers any chat not already watched in one batched write. Each sweep also retries any earlier failed write. |
Daily summary scheduler (_background_daily_summary) |
Fixed 5pm UTC-7 trigger, computed once per cycle | Agent User mode only | The offset is a fixed UTC-7 shift, not DST-adjusted, despite being labeled "PDT" in logs. |
persona-sati heartbeat (_background_persona_sati_heartbeat) |
300s | Agent User mode only | Self-skips (returns "skipped", no warning) whenever PERSONA_SATI_MCP_URL / PERSONA_SATI_MCP_TOKEN_COMMAND are unset — enabling it costs nothing with no persona-sati peer attached. |
Each loop wraps its own iteration in try/except and sleeps before continuing, so a single exception logs a warning and the loop keeps running — there is no separate external process supervising or restarting a task from outside; resilience is per-loop, not fleet-wide.
Token handling¶
_ensure_valid_token() and _with_token_retry() (both in mcp_server.py) are the two token-refresh mechanisms used by Graph-backed tools and background tasks — see Identity and Token Flow for the eager-refresh-and-lazy-retry mechanics. Graph background tasks call _ensure_valid_token() directly before making a call; Graph-backed tool wrappers use _with_token_retry() around the underlying operation. A365/provider-only and local tools may use their provider's authentication or local storage instead of these Graph token helpers.
Body prompt assembly¶
_load_agent_instructions() composes the system prompt FastMCP registers at boot, body-first:
- Body —
prompts/agent_system.mdwith@includeexpansion ofprompts/anatomy/*.md. Loaded first, always, when the file exists. - Persona — when
PERSONA_SATI_MCP_URLandPERSONA_SATI_MCP_TOKEN_COMMANDare both set, Entrabot mints a token via the configured command, opens an SSE session to the remote persona-sati MCP, and calls itsget_system_prompttool. The result is appended after the body. Any failure at any step (missing env, token-mint failure, unreachable remote) falls back to the body alone — boot never crashes because persona-sati is unreachable. - Hardcoded fallback — used only if the body prompt file itself is missing.
This means Entrabot does contact persona-sati directly on this one path (fetching the prompt at boot) even though the runtime resource path (Teams, email, Files) has no dependency on it. Hosts may also list persona-sati as a second, independent MCP server in .mcp.json so the LLM can call its bootstrap_session/observe/reflect/recall tools directly — that is a separate wiring from the boot-time prompt fetch described here.
Host-aware delivery, decided server-side¶
The channel-push notification (notifications/claude/channel) fires unconditionally from the background poll, regardless of which client is connected — clients that don't understand the method simply ignore it, per the MCP spec. Separately, send_teams_message decides whether to auto-block waiting for a sponsor reply by checking the connected client's clientInfo.name (current or last-cached) against a small hardcoded set of recognized channel-push host names in mcp_server.py. This is a server-side lookup table, not a parameter exposed to the model — there is no tool argument the LLM can set to skip or force the wait. See Messaging and Delivery for the full behavior on each side of that check.
Efferent-copy observer mechanism¶
src/entrabot/efferent_copy.py is an opt-in side-channel: when enabled, every registered tool's call fires a fire-and-forget observe(tool_name, args[, result]) call to any MCP peer that looks like it can receive one.
- Opt-in, not default.
discover_sinks()returns zero sinks unlessEFFERENT_COPY_ENABLE=1is set;EFFERENT_COPY_DISABLE=1forces registration off even when enable is set. With zero sinks,install_into_fastmcpwraps nothing and tool behavior is unchanged. - Schema-based discovery, not name-based. A peer is eligible only if its
tools/listadvertises a tool namedobserveaccepting an object-shaped{tool_name: string, args: object}schema. There is no persona-sati-specific name or URL hardcoded into the discovery logic; operators can further restrict the eligible set withEFFERENT_COPY_SINKS=name1,name2. - Wrapping.
install_into_fastmcpwraps every registered tool's underlying function exceptobserveitself (no recursion). Each wrapped call firesobservebefore execution (tool name + bound arguments) and again after (tool name + result), both on a 250ms per-sink timeout viaasyncio.timeout. A timeout or any exception from a sink is caught, throttle-logged, and swallowed — it never propagates to the caller, and the wrapped tool's return value (or raised exception) is unchanged regardless of how many sinks are attached or whether they succeed. - Redaction, not a payload allowlist. Before a payload leaves the process,
_collect_kwargs/_redact_sensitivereplace any argument or (recursively) any dict-result key whose name contains a case-insensitive substring from a fixed denylist (token,secret,password,client_secret,access_token,bearer,credential,private_key, and similar) with the literal string"<redacted>". This is name-based redaction, not content inspection — a sensitive value stored under a non-obvious key name is not caught. Non-JSON-safe values are coerced via dataclass/pydantic dumping orrepr()before being sent, so the argument set forwarded to a sink is not an arbitrary or unbounded payload, but it is also not a curated allowlist of "safe" fields — everything not name-matched by the denylist is forwarded as captured.
Degraded / body-only mode¶
Without persona-sati configured (or with it unreachable), Entrabot runs in body-only mode: identity, Teams/email/Files tools, and audit all continue to work exactly as documented, but no personality, long-term memory, or observe/reflect/recall cognition loop is available. This is distinct from the efferent-copy mechanism above — a session can have efferent-copy enabled with zero eligible sinks (also effectively degraded) independent of whether a host has separately wired up persona-sati's own tools in .mcp.json.
See also¶
- Identity and Token Flow — the auth modes and token lifecycle this runtime drives.
- Messaging and Delivery — background poll, channel push, and the sponsor-DM wait pattern in detail.
- Clients Overview — per-host behavior differences.
- Security Boundaries — the fail-closed model the storage backend check and sponsor-channel binding both belong to.
- Storage Configuration guide — the
MemoryBackendresolution this runtime validates at boot, and how operational memory differs from persona-sati's memory.