Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,41 @@ to it yet.
or the box purging its home; a key the keys part could not forget stays
queued until it has. Only each home's newest roster is applied.

- **MCP at `/mcp`, Streamable HTTP.** It speaks revision 2026-07-28, which
is stateless: each request carries its version and capabilities in
`_meta`, mirrored in `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name`,
and a header that disagrees with the body is refused. It also answers
clients of 2025-03-26 to 2025-11-25 that start with `initialize`, without
session ids or a GET stream. The read tools are `homes_list`, `status`,
`chargers_list`, `cars_list`, `plan_get`, `plan_explain`,
`history_query`, `health_get`, `events_list`, `logs_tail` (both with
before and after cursors), `drivers_status`, `config_get` and
`diag_run` (names any model API accepts), each with typed arguments, each one of the box's own reads for
agents (`live.get`, `loadpoints.get`, `cars.get`, `plan.get`, `price.get`,
`hist.query`, `health.get`, `events.get`, `logs.get`, `config.get`,
`diag.run`, `drivers.get`; srcfl/ftw `docs/agents.md`), under the scope
the registry names for it. The box names chargers and cars by its agent
ids, which the control tools take back. The box refuses the passthrough to agents,
and the readable copy is never an agent's: every read goes through the
box, which audits it and enforces revoke. An agent row has no tunnel of
its own. A call without its scope gets
403 `insufficient_scope`, so the client can ask the owner for more. Each
call rides the agent's own Noise session: `ftwkeysd` seals each frame and
the door carries it as `tun`, one forward to the box. The door admits an
agent row only to these sessions, never on `/v1/app` or `/v1/app/once`,
and only before its end. A session stays warm for 60 s and calls on one
home queue, four at most; a process runs 256 at once, and each agent
may send 30 requests, then 10 a second. Agents' sessions have a budget
of their own at each home, so an assistant never takes a phone's place. Agents' calls on a
home are paced under the box's 60 reads a minute and a quarter of the
door's tun limits, which the home's phones share: 15 in a row, then 0.75
a second. Results are data: each says it came from the box and is not
instructions, and characters that can hide text (controls, format and
private-use characters, variation selectors) are removed. The
session goes to the Noise key the box names in the roster it signs
(`noise`); a box that names none takes no assistants yet, and each such
home says so.

Not built yet:

| Step | What | Where |
Expand Down
8 changes: 8 additions & 0 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/srcfl/ftw-cloud/internal/apns"
"github.com/srcfl/ftw-cloud/internal/appsession"
"github.com/srcfl/ftw-cloud/internal/keys"
"github.com/srcfl/ftw-cloud/internal/mcp"
"github.com/srcfl/ftw-cloud/internal/oauth"
"github.com/srcfl/ftw-cloud/internal/push"
"github.com/srcfl/ftw-cloud/internal/registry"
Expand Down Expand Up @@ -122,6 +123,12 @@ func serve(args []string) error {
}
defer auth.Close()
srv.OAuth = auth
// Each agent's session goes to its box through the door, as tun, to the
// Noise key the box names in the roster it signs. A box that names none
// takes no agents yet, and the tools say so.
agents := mcp.NewPool(st, up, agentKeys, mcp.RosterBoxKey(reg), log)
auth.OnRevoke = agents.Drop

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the revocation callback nonblocking

When a roster revokes an agent with a warm session, oauth.Server invokes OnRevoke inline on its single Run goroutine, but Pool.Drop synchronously closes the connection, writes to the uplink, and waits for keys.End. A slow box or keys daemon can therefore stall subsequent roster approvals, revocations, and expiry sweeps—repeated once per removed agent—despite the callback contract requiring it not to block; dispatch this cleanup asynchronously or make Drop nonblocking.

Useful? React with 👍 / 👎.

srv.MCP = mcp.New(auth, agents, log)

// Alerts: the box's signed events to its phones through Apple, and
// box.unreachable when a box is away. Widgets: a push with no data when
Expand Down Expand Up @@ -205,6 +212,7 @@ func serve(args []string) error {
go srv.Upkeep(ctx, time.Minute)
go pushSvc.Run(ctx)
go auth.Run(ctx)
go agents.Run(ctx)
if err := pushSvc.Resume(); err != nil {
log.Warn("could not restart the dead man's switch", "err", err)
}
Expand Down
Loading
Loading