Skip to main content
Glama

Droid MCP

Droid MCP (droid-mcp) is a small, same-host MCP server that lets Echo (the operator's main assistant) manage Factory Droid sessions: create, message and steer, status, read, wait, find, update, cancel, usage, workspaces and models. Echo talks to Amp and to Droid separately; Amp and Droid do not talk to each other, so sessions run detached (replyTo: null). No UI, database service, Factory daemon or account API. Installation and secure connection steps are in HANDOFF.md; the full, generated tool schemas are in docs/TOOLS.md.

Requirements

  • Linux or macOS (process-group cleanup; enforced at startup) and Node.js 22 or newer.

  • An installed Factory Droid CLI (verified with 0.236.0, normally ~/.local/bin/droid) and the pinned @factory/droid-sdk 0.9.1 from package-lock.json.

  • Factory authentication for the service user: an existing CLI login, or Factory's supported API-key setup for droid itself. The controller inherits that environment and never collects credentials.

  • Absolute, existing approved workspace roots and a private (700) state directory.

  • Optional: an authenticated HTTPS route for remote access (transport:"http" with a bearer token file). The Amp reply-back path (ampMcp) is not used in this setup.

Docker is neither required by the source nor sufficient on its own: a container still needs the CLI, its Factory login, the workspaces and private state. Dependencies are locked; uuid is overridden to 11.1.1 to fix GHSA-w5hq-g745-h8pq.

npm ci --ignore-scripts
npm run check      # syntax + docs/TOOLS.md in sync with the registered schemas
npm test           # mock-peer E2E (real MCP client, controller, worker, Factory SDK)
node src/server.mjs --config /absolute/path/to/config.json

The session tools

A session is a controller-owned handle for one linear conversation with Droid in one approved workspace. Each message you send is one run (a turn). Factory session UUIDs never leave the controller.

Tool

Does

Droid / SDK capability behind it

droid_create_session

Start a session and its first turn. replyTo is required (a Puck conversation id, or null for detached).

SDK createSession over ProcessTransport (droid exec --input-format stream-jsonrpc)

droid_send_message

Follow up or steer. Idle starts at once; busy queues (default) or, with interrupt:true, interrupts and resumes.

SDK resumeSession, session.interrupt(); the controller serializes (see Steering)

droid_get_session_status

agentState, metadata, preview, latest run, declined questions, reply-back notification.

Controller records plus the SDK terminal result

droid_read_session

Paged history.

Terminal SDK result messages the controller retained

droid_wait_for_sessions

Join up to 10 sessions; bounded; returns when all settle.

Controller bookkeeping, no Droid call

droid_find_sessions

Text (title, labels, bounded output preview/tail) and typed filters, newest created first, keyset cursor.

Controller records (Factory's droid search and SDK listSessions are not used: they would expose unrelated service-user history)

droid_update_session

Title, labels, archive.

Controller metadata (no private Factory files are edited)

droid_cancel_session

Interrupt the running turn, drop queued turns.

session.interrupt() then process-group kill after the grace period

droid_get_usage

Sum of per-turn token usage and Factory credits.

DroidResult.tokenUsage (including factoryCredits)

droid_list_workspaces

Approved roots, capacity, launch policy.

Controller configuration

droid_models

Live model catalog.

initializeSession availableModels from the authenticated runtime

Typical flow (tool arguments, not shell commands):

{"requestKey":"review-42","workspace":"/approved/repo","prompt":"Review the diff and report findings.","autonomy":"off","replyTo":null,"title":"Diff review"}
  1. droid_list_workspaces (roots, capacity, policy including defaultModel); droid_models only when you want a model other than the host default.

  2. droid_create_session with a stable requestKey; keep metadata.session.

  3. droid_wait_for_sessions({sessions:[...]}) in a loop; a timeout is not failure.

  4. Inspect latestRun.state, needsAttention, notification; droid_read_session for detail.

  5. droid_send_message to continue. Mid-run steering needs interrupt:true; the default interrupt:false queues after the current turn (this differs from Amp thread messages, which do not interrupt either, but do inject).

Concurrency (a hard requirement)

Sessions run at the same time. maxConcurrentRuns defaults to 4 (1 to 16) and is shown by droid_list_workspaces. Admission is FIFO and applies three rules:

  1. at most maxConcurrentRuns live turns;

  2. at most one live turn per session;

  3. canonical workspace locks: autonomy:"off" turns are readers and may share an overlapping tree; any other level is a writer and needs its whole canonical tree (ancestors, descendants, symlink spellings) to itself.

A turn that cannot start is queued (it was not submitted to Droid) and starts when capacity and locks allow, in order, so a waiting writer is never starved by later readers. Locks are held until the worker and its Droid process are cleaned up. There is no cross-controller or OS-level lock. Queues are bounded (64 total, 8 per session): beyond that queue_full. Queued prompts live only in memory, so after a controller crash queued turns become cancelled with queue_lost; they were never submitted and are never replayed.

Steering is serial interrupt-and-resume

interrupt:true durably cancels older queued turns on that session with superseded, retaining their request keys and history. It then interrupts the active turn through the protocol, waits for terminal result or termination and process cleanup, then passes workspace/capacity admission before resuming the same Droid session with your message. The SDK's high-level session rejects concurrent streams, so native in-flight injection is not claimed or implemented. Tool side effects of the interrupted turn are not undone. Cancelling or steering one session never touches another.

Outcomes, attention and notification

latestRun.state

Meaning

queued

Accepted, waiting for capacity or a workspace lock; nothing submitted.

starting, running, cancelling

Live. running means initialized, not continuously producing output.

succeeded

Matching terminal SDK result with subtype success (not proof the task was done).

interrupted

Terminal SDK result interrupted (cancelled, steered, or a rejected permission).

failed, timed_out, cancelled

Setup/agent failure; deadline; cancelled without a terminal result (including dropped queued turns).

unknown

Controller stopped mid-turn. Never replayed, never inferred as success; the session cannot continue.

A turn whose worker dies without a result is failed, and turns queued behind it on that session are dropped unsubmitted (predecessor_failed) so Puck decides what to do; the queue never resumes work across a failure. That cancelled head still needs attention and exposes latestRun.predecessorFailure with the failed run handle, state and error. A successful resolving follow-up clears attention; historical failures are not permanent flags.

agentState is working (anything queued or live), idle (nothing pending; not a success claim) or unknown. droid_wait_for_sessions treats every non-working state as settled, including needsAttention, so Puck is never left blocked. needsAttention is true for failures, unknown outcomes, an interrupt nobody asked for, declined permissions (permissionsDeclined), declined AskUser questions (latestRun.questions, answer them with droid_send_message), and failed reports. SDK 0.9.1 maps a completed turn to success even after a declined AskUser or tool, so check these fields and the requested artifacts.

notification is what the controller observed the agent do with amp-puck___puck (it cannot send for the agent): disabled (detached), pending (turn running), accepted (Amp accepted a report; not proof Puck read it), failed (rejected, or sent to a conversation other than replyTo, reply_misrouted), not_sent (the turn ended without a report). A failed notification never turns a finished run into a failed run.

Breaking change: session API only

droid_start, droid_continue, droid_status, droid_result, droid_list and droid_cancel are removed, not hidden. The catalog contains exactly 11 tools. Old names fail at dispatch; there are no aliases or compatibility flags. Callers must use session handles with create/send/status/read/find/cancel session tools and supply replyTo on creation. model is optional (see Launch defaults). Refresh cached MCP catalogs. droid_models is unchanged. Internal run IDs remain turn identifiers, not a second API. The repository's smoke/acceptance drivers use the session API; external clients that imported the removed names must change.

Related MCP server: windlass

Models come from the live authenticated runtime

droid_models initializes a short-lived Droid connection with the same CLI, service user, HOME and FACTORY_HOME_OVERRIDE as normal runs, captures the live availableModels, closes the session, and never submits a prompt or approves a tool. Create and send validate the effective model and reasoningEffort against that catalog and reject with model_unavailable / reasoning_unsupported. Entries with disabled:true or deprecated:true are omitted; optional metadata is returned only when Factory supplies it. CLI 0.236.0 supplies deprecated in initialization; the pinned SDK strips it, so raw capture is correlated to that initialization's request ID before SDK parsing. Missing lifecycle metadata is unknown, not proof a model is non-legacy. Consult Factory's current catalog and the installed droid exec --help deprecation labels when metadata is absent. modelCacheTtlMs defaults to 60000 (5000 to 600000); concurrent callers share one refresh; a failed refresh returns model_discovery_failed, never an expired catalog.

Launch defaults and follow-up inheritance

Every new turn records its effective model, autonomy and reasoningEffort (shown in latestRun). They are resolved once, at acceptance, against the live catalog:

Setting

Explicit

Omitted on droid_create_session

Omitted on droid_send_message

model

Must be in the live catalog

Host defaultModel, live-validated; model_required if unset

The session's last accepted model (not the host default)

autonomy

Above maxAutonomy is rejected, never silently downgraded

Host defaultAutonomy

The session's last accepted autonomy, capped at the current maxAutonomy

reasoningEffort

Must be supported by the selected model

Host reasoningEffort if supported, otherwise the model's live default

Same model: the session's last effort if still supported; then host effort if supported; then the model's live default

"Last accepted" includes queued work, so FIFO follow-ups see the settings of the turn queued before them. A follow-up therefore never promotes a read-only session to the host's high default, and a model change never carries the old model's effort (the same rule Factory documents for subagents switching models). When nothing supported is known for a changed model, the send is rejected and asks for an explicit reasoningEffort.

defaultModel must be a standard model. A Fast variant is refused automatically: at startup when the id looks like Fast, and at launch when the live display name does. This is a name-based heuristic (-fast, Fast Mode), not catalog metadata. A caller can still choose a Fast model explicitly. The recommended host values are "defaultModel": "claude-opus-5-5" (standard Opus 5.5, if present in your live catalog) with "reasoningEffort": "high".

Accepted request keys replay their original effective settings, without consulting the catalog, even after defaults, the catalog or the ceiling change. Omitted fields in a replay compare against those recorded values; a different explicit value is request_key_conflict. Existing records need no migration.

Direct launch from Puck

Routine work needs no Amp preparation thread, runner, worktree or controller restart: Puck calls the connected Droid MCP and the controller starts a Factory Droid session.

One-time host setup (operator, per host or project):

  1. Install, authenticate the service user's Droid CLI, configure state and transport (HANDOFF.md).

  2. Approve each project root in approvedDirectories; restart the controller when idle.

  3. Set defaultModel (and optionally reasoningEffort, autonomy policy).

  4. Only for reply-back: ampMcp plus the one-time Amp OAuth sign-in for its exact URL.

Per task, Puck only calls droid_create_session with requestKey, an approved workspace, prompt and replyTo (null for detached work), then droid_wait_for_sessions, droid_read_session and droid_send_message. Detached sessions never contact Amp, so they need no Amp authorization. An unapproved project fails with workspace_not_approved, whose action names the one-time setup.

Choosing a project

Agents pick a project from the controller, not from memory:

  1. Call droid_list_workspaces. workspaces lists the approved roots (canonical absolute paths) of this host's approvedDirectories.

  2. Pass one of those roots, or any existing subdirectory or Git worktree inside one, as workspace to droid_create_session. Symlinks are resolved first; a path that resolves outside every root is rejected.

  3. Follow-ups use the session handle; the workspace stays fixed for the session.

Adding a project root is an operator change to the private host config, effective at the next controller-only restart. Agents never edit it.

Isolation: writer turns hold a lock on their whole canonical workspace tree, so two sessions writing the same tree run one after the other. For parallel writers, the operator provisions separate directories or Git worktrees inside an approved root once, and Puck targets them directly. The controller does not create worktrees. Droid's native --worktree places checkouts under worktreeDirectory (default ~/.factory/worktrees), outside the approved roots, and changes the session cwd, which the controller's resume check rejects; enabling it would widen the approved filesystem scope and is left as an explicit operator decision.

Factory web visibility

The controller runs Droid through SDK ProcessTransport (droid exec --input-format stream-jsonrpc) as the service user. Factory documents cloudSessionSync (default true, org settings can override it) as mirroring CLI sessions to Factory web, so such sessions are expected at https://app.factory.ai/sessions/<Factory session UUID>. Whether a given session is actually visible has to be checked in an authenticated browser; local history alone is not proof of upload. Factory session UUIDs stay out of the tool surface; the operator can map controller sessions to them from sessions[*].droidSessionId in the private state.json. Registered-computer Remote Control (droid computer register, droid daemon --remote-access) is a different feature. It is not used or enabled here, and enabling it would let external clients bypass the controller's root locks.

Titles and tags. A session created with an explicit title gets that title in Factory through SDK rename() after its first turn (renaming earlier would be replaced by Droid's generated title). A droid_update_session title change is applied after the session's next turn; the update itself never starts Droid outside admission. Sessions without a title keep Factory's generated title. Rename is best effort and runs after the worker has delivered the turn's terminal result, which ends the execution deadline, so a slow or failed rename cannot make a finished turn time out. It then gets its own cleanup window of min(10 s, runTimeoutMs / 4) (plus cancelGraceMs); the execution slot and workspace lock stay held until the worker exits. A failure or an unfinished rename is logged to the controller's stderr and the run's events and retried on the next turn. It never changes the turn's outcome or request-key fingerprints. Turns that are cancelled or time out are not renamed. Labels are sent as Factory session tags at creation, beside the SDK's own sdk tag (a label named sdk merges with it). SDK 0.9.1 has no tag update call, so later label changes stay controller metadata. Whether the Factory web app displays these tags is unverified.

Droid reports and questions use the actual Amp MCP

Reply-back is opt-in per host: "ampMcp": {} in the private config. The endpoint is generic: https://ampcode.com/mcp?profile=puck, the only profile Amp currently accepts (others return HTTP 400 Unknown MCP profile; expected one of: puck). Any other ampMcp.url is refused at startup; there are no aliases. It names no thread and no recipient; a threadID in the URL, or the old puck.conversationId, is rejected at startup, because a thread-bound endpoint broke every launch when its thread was archived and a host default recipient silently misrouted omitted replyTo. Recipients are per session (replyTo) and are never defaulted.

The controller attaches amp-puck through SDK mcpServers on create and resume, only for routed sessions (replyTo non-null); detached sessions never touch the endpoint. Tool exposure is default deny: only amp-puck___puck (send, read_reply) is meant to be usable. The earlier thread-free profile was observed live to offer manage_amp (admin) and find_thread / read_thread (read other Amp threads) too, so those start in disabledToolIds; the preflight then lists what the server actually exposes, denies every other amp-puck___* tool it finds (a new Amp tool is blocked automatically), and verifies none remains usable. Saved disables are preserved on resume and when updating settings; native tools are not restricted. Discovery/settings errors and cancellation submit no prompt. If denial cannot be achieved the run fails with amp_mcp_admin_tool_exposed and nothing is submitted. This is client-side filtering, not credential scoping (see docs/THREAT-MODEL.md). Detached sessions never touch the endpoint.

Factory stores Amp MCP OAuth tokens per exact endpoint URL (observed live: the thread-free URL and a differently threaded URL both get unauthorized on a host that authorized only another URL). The operator must complete Factory's supported MCP OAuth once, as the service user, for the exact configured ampMcp.url. See docs/CUTOVER.md. The controller never accepts tokens or headers and never starts consent during task execution.

Before any task prompt is submitted a preflight checks the server and tools and fails the run with an actionable latestRun.error instead of the generic one:

error.code

Cause

amp_mcp_auth_required

Server listed but no OAuth tokens.

amp_mcp_not_started

Server dropped from Droid's listing: usually no stored token for this URL, or Amp rejected the connection. (The SDK does not report these as failed; the old Unknown tool identifier(s) error was this case, masked by the disabled admin tool id.)

amp_mcp_unreachable

Server listed as failed or never connected; the sanitized server message is included (for example an archived thread).

amp_mcp_tool_missing / amp_mcp_admin_tool_exposed

puck not exposed / any other Amp tool usable.

amp_mcp_setup_failed

Tool discovery failed for another reason (sanitized).

Every routed turn receives the explicit recipient, the controller session and run handles, and how to call {action:"send",params:{conversationID,message}} and {action:"read_reply",params:{replyHandle}}. Routed sessions may approve, as a single use, only that tool: send to their own replyTo, or read_reply with a recognized handle proven by an originating-turn approved own-recipient send and its correlated non-error result (so read-only reviewers can report). Ownership is durable within the same controller session and uninterrupted recipient route; continuation and restart preserve it. Accepted detach/retarget invalidates old handles, even if that turn is cancelled or routing later returns. Missing, malformed, foreign and unproven handles are denied even at autonomy high. Replayed call/result notifications without a new approved send cannot grant ownership. The parser accepts a top-level JSON replyHandle in string/text-block results; unknown formats fail closed. Other high-autonomy single-use approvals remain unchanged. These rules are not OAuth credential scoping or OS isolation.

Progress is sent once, fire-and-forget; continue authorized independent work. A question, blocker or costly/irreversible step needing steering uses one CHECKPOINT with recipient, session/run handles, marker, completed evidence, proposed action and decision needed. Consume a completed reply inline, otherwise read only that send's exact replyHandle, at most 6 reads in the natural tool loop, without messaging waits. Never resend after timeout/ambiguous failure or use latest-active/empty params. Without an answer, end BLOCKED with marker, decision and handle; leave dependent work untouched. Call Puck directly in Spec mode; never exit Spec just to message or guess AskUser answers. Acceptance/queued/working/silence is not approval; replies steer only within existing authorization. Wording cannot override permission cancellation; mock text pins prove wiring, not model obedience.

A controller-owned Amp OAuth client and durable terminal-notice outbox are not implemented: no supported credential path exists (Amp remote-MCP OAuth is per-account via ampcode.com; Amp Workload Identity needs Amp-staff access; Factory's token store must not be read or copied). Terminal outcomes are therefore delivered by droid_wait_for_sessions and status, and the agent's own report is observed in notification. See docs/THREAT-MODEL.md for the open question.

OAuth is account authorization, not a demonstrated thread/tool-scoped grant. Client-side tool denial reduces model exposure, not credential rights. See Factory MCP and Amp MCP.

Outcome details

An unknown outcome is settled for waiting but not a known Droid outcome. The session cannot continue (session_unknown_outcome), even through an older run handle. Inspect/reconcile Droid history and the workspace locally. Start a new session with a new key after reconciliation, or use the saved Droid session manually outside the controller. Do not edit state to manufacture success.

High-autonomy turns record and approve offered single-use permissions with ProceedOnce, never persistent ProceedAlways rules. Other levels decline pending permission requests with Cancel; high also declines if no single-use option is offered. AskUser questions are recorded and declined, not guessed. SDK 0.9.1 maps a completed turn to success even after declined AskUser and with empty final text; cancelled maps to interruption. The controller preserves that SDK outcome, not a judgment that the user's task finished. Inspect recorded ask_user_declined events and task artifacts before accepting completion. Puck can answer the blocked question with droid_send_message. There is no interactive permission queue or arbitrary session import; saved outcomes are not rewritten to manufacture a different result.

Same-host durability and cleanup

stateDirectory contains private state.json, per-run *.result.json, and an owner record. Writes use file+directory fsync and atomic replacement. A single process owns the state; startup refuses a live owner, another host/HOME/profile, or malformed state. A stale dead-process owner is recovered under a startup lock. PID reuse conservatively blocks startup rather than risking two owners.

A controller crash stops its workers through IPC disconnect; each worker owns a separate process group containing Droid. Cancellation first sends protocol interrupt, then force-kills that group after its grace period. The session slot is not released until worker cleanup. Terminal results are persisted before cleanup, so restart can retain a known outcome even if cleanup was interrupted. Processes deliberately detached by Droid tools may escape that group: do not authorize background-service work unless separately supervised.

Keep the same user, HOME, FACTORY_HOME_OVERRIDE (if used), Droid session storage, and workspace. Controller records do not replace Factory's own saved history. Back up both. Progress is intentionally bounded: last 20 events, 4000 characters per event field, and 16000-character text/stderr tails. Final SDK results are stored with submitted user-message copies omitted. There is no automatic deletion of completed records; archive them locally with the controller stopped, preserving keys if deduplication is needed. Timestamps are UTC ISO strings; Puck may display them in Asia/Bangkok.

Original submitted prompts are not retained in controller state. Prompt content participates in the SHA256 intent fingerprint and is passed to the worker only over IPC for the accepted execution. A crash before submission becomes unknown, not a replay. Assistant echoes, permission context, stderr and final results can still contain sensitive text; Factory's own session history is separate and may retain prompts. Fingerprints are not encryption.

Only current version 3 state is supported: sessions (title, labels, archive flag, replyTo, workspace, authoritative headRunId) and runs, including durable replyHandles and replyRouteId. Older formats and incomplete records refuse startup without rewriting. There are no automatic migrations or backfills. Existing current sessions, results, request keys and credentials are preserved. See docs/STATE-MIGRATION.md for requirements and recovery.

Reference host setup

The setup in use (stdio, localhost only, no tunnel) matches config.example.json: defaultModel claude-opus-5-5, reasoningEffort high, maxAutonomy high, maxConcurrentRuns 4 and excludeModelIds [] (no legacy list populated; the runtime already hides deprecated models and models with a deprecationDate). defaultAutonomy is omitted, so it falls back to maxAutonomy (high); set it explicitly for a lower default.

Register it as a stdio MCP server named droid-mcp:

node /absolute/path/to/droid-mcp-controller/src/server.mjs --config /absolute/path/to/config.json

No env vars are needed when the service user's droid CLI is already logged in. For steering sessions from the Factory dashboard, run droid daemon --remote-control on the same host (this host is registered as computer grok-bot).

Configuration and security

Copy config.example.json and replace every path with an absolute path. JSON paths do not expand ~. Only real directories at/below approved roots can be launched; traversal, sibling-prefix and symlink escapes are rejected. Resume must resolve to exactly the recorded workspace. State must be private (700 directory, 600 files).

Optional settings:

Setting

Default

transport

stdio (no network listener)

maxAutonomy

high (owner-authorized service-user access)

defaultAutonomy

Inherits maxAutonomy (high when both omitted); an explicit default cannot exceed the ceiling

defaultModel

Unset (create then needs model). A standard live-catalog id used when create omits model; Fast variants are refused

reasoningEffort

Unset; otherwise off, none, dynamic, minimal, low, medium, high, xhigh, or max. Used when supported by the selected model, otherwise the model's live default applies

excludeModelIds

[]. Exact model ids hidden from droid_models and refused on create/send (e.g. models the Factory dashboard lists as legacy but the runtime does not flag). Models with deprecated:true, disabled:true or a deprecationDate are always hidden

maxConcurrentRuns

4 (1 to 16); queued turns wait FIFO

runTimeoutMs

3600000 (one hour wall clock, including setup)

cancelGraceMs

5000, also bounds terminal cleanup

modelCacheTtlMs

60000, bounded to 5000–600000

ampMcp

Unset; {} enables reply-back with the generic Amp endpoint. url is optional and must equal https://ampcode.com/mcp?profile=puck. The old puck key is rejected

port

8787, HTTP only; binds 127.0.0.1 only

tokenFile

Required for HTTP; private file, ≥32 URL-safe random characters

publicUrl

Optional approved HTTPS proxy URL, ending in /mcp

The listener implements stateless Streamable HTTP MCP POST /mcp, with constant- time Bearer authentication on every request, approved Host/Origin checks, a 1 MiB body cap and request timeouts. No unauthenticated HTTP option exists. No OAuth provider, SSE notification channel, or automatic push back to Puck is provided. Status and results work independently of a connected client; waits are bounded to 60 seconds per call.

Malformed JSON/MCP envelopes return HTTP 400. Unexpected server/transport failures return generic HTTP 500 Internal MCP server error; details stay in local stderr. Lifecycle/tool rejections are normal MCP isError responses with {error:{code,message,retryable,action}}, not HTTP 500.

Workspace approval is a launch restriction, not an OS sandbox. Droid runs as the service user; enabled tools, inherited MCP servers, project hooks, and commands can access what that user can. Inspect trusted project/Factory config. For hard file/network isolation use a dedicated restricted account/container with the approved directories mounted. Read-only/Spec mode is Droid policy, not a filesystem mount guarantee. Low/medium/high authorize real actions; high is powerful. The controller never uses --skip-permissions-unsafe.

Treat the bearer token as remote access to this account within the selected ceiling. Anyone with it can read stored results and start tasks. Store it only in local/private credential settings, use TLS for every remote hop, and restrict the HTTPS proxy/tunnel to this controller. Never expose an orb preview as the user's installed controller. Rotate the token by replacing its file and restarting the server; update Amp's stored credential separately.

Workspace approval, outages, and safe upgrades

  • Workspace rejection: a correct authorization failure, not a reason to remove the allowlist. Ask the operator to add only the explicitly approved project root. Existing directories, real paths, sibling/traversal protection, and resume-cwd checks still apply. Configuration is loaded at process startup; coordinate a controller-only restart with every caller to activate changes.

  • HTTP 530: a failure at the HTTPS/Cloudflare boundary; it does not establish that Droid failed or stopped. Check the actual public route and Cloudflare's tunnel connection state, DNS, and bounded logs. Loopback readiness/metrics can appear healthy while public routing is down. Synthetic host DNS can also make a local HTTPS probe misleading; cloud-client acceptance is required. Reserve connector-only recovery when public routing fails; preserve active Droid runs. A controller restart cannot repair an unavailable tunnel. Retry read-only status/list/result after a short delay; replay start/continue only with the same key and identical arguments when acceptance is uncertain. Never create a second intent merely because HTTP failed.

  • Connector DNS: some managed hosts return synthetic addresses that cannot carry tunnel traffic. Fix only the connector's resolution/routing, not shared machine DNS. A pinned-edge override is a host workaround, not a portability guarantee; retain edge TLS verification and revisit it if edge addresses change.

  • Upgrade/restart: reserve the execution slot, check that all runs are terminal, back up private state, and restart only the changed service. Keep the healthy tunnel, bearer credential, HTTPS URL, user/HOME, and state store unchanged. Recheck remote discovery, prior results, list, and idempotency before resuming. Never rewrite installation state to make a failed upgrade appear successful.

  • Persistence: process supervision and durable records do not imply machine- boot startup. Use the host's supported startup mechanism when available; a managed container without systemd/launchd needs platform-specific provisioning. Do not claim boot persistence from a successful process restart alone.

Protocol evidence and tests

Uses published @factory/droid-sdk/node ProcessTransport with createSession({transport}) / resumeSession(id,{transport}). This bypasses the SDK helper's API-key requirement, not CLI authentication. The supported CLI invocation is exec --input-format stream-jsonrpc --output-format stream-jsonrpc. Settings use initialize/update requests, not CLI model/autonomy flags. Terminal matching is agent_turn_completed.turnId == add_user_message.messageId; idle, assistant text, silence, unrelated turn IDs and process exit cannot succeed.

npm test drives the real MCP SDK client, controller, worker, Factory SDK and a mock JSON-RPC peer (test/e2e.test.mjs for transport, SDK and persistence safeguards, test/session-surface.test.mjs for the session tools). It covers the strict schemas and errors, idempotency, steering and queueing, N parallel sessions, capacity queueing, overlapping-workspace serialization (both lock directions, symlinks, FIFO fairness), mixed autonomy, concurrent reply-back with per-session recipients, misroute detection, cancel/steer isolation, Amp MCP preflight failures, launch defaults and follow-up inheritance (test/settings-defaults.test.mjs), Factory title and tag sync (test/factory-title.test.mjs), removed-tool rejection, unsupported-state refusal, crash fail-closed and queue loss. See test/README.md. test/review-regressions.test.mjs and test/worker-ack.test.mjs additionally test durable queue supersession, persistence ACK fault boundaries, per-run reply handles, failed-predecessor attention and catalog lifecycle. npm run smoke runs a two-turn lifecycle against a real CLI, and scripts/acceptance.mjs is the live acceptance driver for a second instance (own port, token and state directory), never the production service.

For bounded authenticated read-only verification with disposable private state: node scripts/verify-live.mjs --droid /absolute/path/to/droid --model CURRENT_ID --workspace /approved/smoke/root. It checks real SDK acceptance, two exact results, same-UUID continuation and cancellation of a third turn, then process cleanup; output contains only assertions and versions, not prompts, session UUIDs or credentials. Pick an economical current model with low reasoning from the live catalog first. Existing CLI login is required. node scripts/verify-route.mjs --droid /absolute/path/to/droid separately checks fresh, resumed, reattached and detached MCP inventories without any model prompt or Puck message. It requires existing OAuth for the generic Amp endpoint. On CLI 0.236.0 / SDK 0.9.1, omitting attachments on resume does not retain a previous Amp attachment. This does not prove future CLI behavior or constrain inherited host MCP.

Sources: Factory SDK documentation, Droid exec, published SDK, Node IPC send callback semantics, reference bridge. The reference bridge was cloned/read, not changed or incorporated: its OpenAI-transcript/tool isolation contract differs from this session controller.

License

MIT; see LICENSE.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive

Related MCP Connectors

Related MCP Servers