Droid MCP Controller
by teobucos
README.md
# 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](HANDOFF.md); the full, generated tool
schemas are in [docs/TOOLS.md](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.
```sh
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):
```json
{"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.
## 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](https://docs.factory.com/docs/models)
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](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](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](https://docs.factory.ai/harness/mcp) and
[Amp MCP](https://ampcode.com/docs/customize/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](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`:
```sh
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](https://docs.factory.com/sdk/typescript.md),
[Droid exec](https://docs.factory.com/docs/droid-exec/overview),
[published SDK](https://github.com/Factory-AI/droid-sdk-typescript),
[Node IPC send callback semantics](https://nodejs.org/download/release/v22.16.0/docs/api/child_process.html#subprocesssendmessage-sendhandle-options-callback),
[reference bridge](https://github.com/mrwogu/factory-droid-openai).
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](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive