Skip to content

feat(mcp): control within the owner's grant, and requests that wait for the owner - #21

Merged
frahlg merged 29 commits into
mainfrom
feat/mcp-control
Oct 15, 2026
Merged

frahlg merged 29 commits into
mainfrom
feat/mcp-control

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Fourth of four stacked PRs. It is based on #20 (MCP read), which is based on #19 (OAuth) and #18 (keys).

What this adds

Built to the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md).

Tool Sends Checked here first
ev_charge_now (marked destructive) cmd loadpoint.hold {id, power_w, hold_s, phase_mode}, or {id, clear} with stop the op in the grant; each argument by protocol.RegistryAgentArgs (kind, range); power_w from 1 to caps.maxW (the box reads a missing power as a pause); hold_s 1 to caps.holdS, never 0, 15 minutes by default
goal_request agent.request {op, args, why} for site.mode.set, planner.prefs.set, loadpoint.surplus_only.set, loadpoint.soc.set the op in the grant; the same registry rules; why ≤ 280 bytes
requests_list agent.list —
  • Holds end by themselves. A hold always has an end, so control ends when the assistant stops renewing, and the service never renews one by itself. An owner's own hold wins (E_NEEDS_OWNER, in words). The caps are the owner's, from the roster. The service may hold a call to them; it never loosens them.
  • battery.hold is gone. The contract no longer opens it to agents.
  • Lasting changes wait for the owner. They go as agent.request and wait for an owner's Face ID. requests_list shows how each ended.
  • The first command knows the revision. The control revision comes from the chargers' answer, read before the first command if need be, so the first command spends nothing of perMin on a conflict. A stale revision is learnt from E_CONFLICT and tried once more.
  • A retry is the same command. A client's retry of the same call (same JSON-RPC id, op and arguments) within a minute reuses its UUIDv7, so the box acts once. A renewal later is a new command, with 30 s of validity in the box's uptime.
  • A lost answer is unknown, not failed. Once a command is sent, a session that ends (a restart's drain, a reconnect, the box) or a call that runs out loses the answer, not the command. The tool says state: unknown, "Unknown, not failed", and to check chargers_list before trying again.
  • The report claims nothing unconfirmed. It says what the box confirmed. A command the box refused is a tool error (isError), in words: another hold wins, a cap, no hold to stop, no clock.

The official inspector

TestInspectorSmoke runs the MCP inspector's CLI (2.3.0) against the whole path: a real token, the stub box, tools/list (all 16 tools) and two tools/calls. It runs when MCP_INSPECTOR names the command. It passed here offline, from the npm cache, and CI now runs it with npx. The inspector speaks 2025-11-25 with initialize, so it exercises the earlier-era path; the 2026-07-28 path is covered by TestTheTransport.

What the tests prove

  • Without the right, nothing moves. A read-only grant gets 403 insufficient_scope naming the scope, and nothing reaches the box.
  • Charge now is one command. It has the exact arguments (hold_s 600 for 10 minutes, phase_mode), the revision the chargers' answer named, a UUIDv7 id, and a validity within a minute of the box's uptime. Without minutes it lasts 15 minutes; stop sends clear.
  • Charging needs a power. Without power_w, or with 0, nothing is sent.
  • Past a cap, outside the registry's arguments or unknown, nothing is sent: past holdS or maxW (the message names the cap), a phase mode the registry lacks, a path as an id, an extra field, a mixed or reasonless goal, safety_k 3, battery_export "maybe", a level of 101 %.
  • The registry rules hold op by op (TestArgumentsFollowTheRegistry).
  • An op the owner took out of the grant is refused here.
  • A stale revision is tried once more, and a retry is the same command id while new arguments get a new one.
  • A lost answer is unknown, not failed: a drain while the box holds the command, and a call that runs out with nothing heard back.
  • Refusals are tool errors in words: another hold, a cap, no hold to stop, a box without a clock (at once, not after a timeout).
  • ev_charge_now is marked destructive, and status is not.
  • Each goal kind becomes its op as an agent.request, says it waits for the owner, and requests_list reads the agent's own row.
  • The official inspector lists all 16 tools and calls two.

Not in this PR

🤖 Generated with Claude Code

A second process on the same host, as another user, behind a unix socket
whose peer uid it checks. It makes an assistant's X25519 agent key when an
authorization starts and seals it at rest: one AES-256 data key per agent
key from AWS KMS (a file key in development), with an encryption context
that names the purpose and the key id. A key stays pending until the owner
approves, and opens no session until then; an unapproved key goes after an
hour.

It unseals a key only to open one Noise IK session to one box, with the box
session's prologue set by ftwkeysd itself, and wipes the secret once message
1 is written. It keeps the transport keys and seals and opens each frame for
ftwcloudd, which carries them as tun. Forget deletes the key and ends its
sessions. Errors on the socket are a few fixed words, and nothing logs a
key.

Deploy files, not run: a compose service under the `agents` profile on the
host's network (the one container that reaches the instance role with an
IMDS hop limit of 1), the KMS key policy (root may administer, only the host
role may use, only with the purpose), the role's inline policy, and a host
script for the socket directory. The guide's first invariant changes as
decided on 15 October 2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:34:00.879627Z 0469392 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 046939268e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/mcp/control.go Outdated
if a.PowerW != nil {
power = *a.PowerW
}
if power < 0 || power > maxBatteryW || (a.Direction != "idle" && power == 0) {

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 Reject nonzero power for idle battery holds

When direction is idle, any power_w from 1 through 20,000 passes this check and is sent to the box, despite the schema defining idle power as zero. This allows a contradictory control command such as {direction:"idle", power_w:20000} to bypass the cloud-side bounds and leaves its interpretation to the box; reject nonzero power whenever the direction is idle.

Useful? React with 👍 / 👎.

Comment thread internal/mcp/control.go Outdated
if err != nil {
return nil, err
}
out.(map[string]any)["waitsForOwner"] = true

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 Report owner waiting only after goal acceptance

If the box rejects goal.request (for example with E_UNKNOWN_OP) or does not answer, command still returns a report and this unconditional assignment adds waitsForOwner: true. The resulting response can simultaneously say state: rejected or state: unknown and claim that the request is waiting for approval, causing the assistant to tell the user that a nonexistent or unconfirmed request is pending; set this only for a state that confirms acceptance.

Useful? React with 👍 / 👎.

Comment thread internal/mcp/control.go
Comment on lines +270 to +272
if res.Observed != nil {
out["observed"] = map[string]any{"value": res.Observed.Value, "source": clean(res.Observed.Src)}
}

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 Sanitize observed command values before returning them

When a command result contains a string (or nested strings) in Observed.Value, it is copied directly into MCP structured and text output, unlike API results which pass all box-provided values through cleanAll. A box or device-provided value containing bidi/formatting or control characters therefore bypasses the service's output sanitization and can hide or misrepresent text shown to the user; clean the observed value recursively before returning it.

Useful? React with 👍 / 👎.

The agent contract (srcfl/ftw#1642) has FTW Cloud sign each grant offer the
consent page hands the owner's app, with a key the app pins. It lives in
the keys part, apart from the agent keys: one P-256 key, made at the first
start, sealed like an agent key under its own context, kept in memory.
It signs only the deterministic CBOR ["ftw.offer.v1", offer]. VerifyOffer
is the app's check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg frahlg changed the title feat(mcp): capped, temporary control, and requests that wait for the owner feat(mcp): control within the owner's grant, and requests that wait for the owner Oct 15, 2026
@frahlg
frahlg force-pushed the feat/mcp-control branch 2 times, most recently from 7d6f279 to 341d90b Compare October 15, 2026 18:00
The contract's signed offer is {o, kid, sig}, and Core and the apps pin
offer keys by kid. The kid is the first 16 characters of the base64url
SHA-256 of the key's SEC1 point. ftwkeysd logs kid and key when it makes
the offer key, and `ftwkeysd offerkey` prints them from the same state
directory; deploy/keys.md adds the pinning step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
…ows the revision

From the review of #21:

- B1: ev_charge_now without power_w paused the car and said it worked,
  since the box reads a missing power as 0. Charging now needs power_w of
  at least 1 (the schema says so too); stop is the way to release.
- S1: the control revision comes from the chargers' answer, read before
  the first command when this session has not seen one, so the first
  command no longer spends the grant's rate on a conflict.
- S2: a box without a clock refuses control at once, in words (hello's
  refusal now reaches the command; see #20).
- S5: ev_charge_now carries destructiveHint.
- S6: stop where this assistant holds nothing says so (E_NOT_FOUND).
- N5: a hold without minutes lasts the default 15, shortened to holdS.
- N6: a command the box refused is a tool error (isError), in words.
- N15: a client's retry of the same call (same JSON-RPC id, op and
  arguments) within a minute reuses the command id, so the box acts once;
  a renewal later is a new command.
- Tool names use underscores (ev_charge_now, goal_request, requests_list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 11 commits October 15, 2026 22:49
…r key in KMS, a role of its own

- signOffer decodes the GrantOffer strictly and signs only version 1, the
  agent role, at most OfferTTLMs ahead, and a key that is a pending kid's.
  The pin of the agent contract (e2559632) moves here from #19 for it.
- The offer key is an asymmetric KMS key (ECC_NIST_P256, SIGN_VERIFY) that
  only the keys role may sign with. A local offer key and the file sealer
  exist only in a development build (-tags ftwclouddev).
- ftwkeysd leaves the host network for a bridge of its own and uses the
  ftw-cloud-keys role, which may use the two KMS keys and nothing else. The
  host renews its credentials into tmpfs every 15 minutes.
- Key policies: kms:CreateAlias instead of kms:Create*, an explicit deny
  on kms:CreateGrant, and keys.md says plainly that PutKeyPolicy lets an
  admin take use.
- ftwcloudd runs as 65530, not distroless's shared nonroot. ftw-cloud-own
  gives its files to the user its image runs as at every start, and
  ftw-cloud-deploy keeps the lines keys.sh adds to compose.env.
- Pin the offer key from ftwkeysd on the host through SSM, not from the
  public endpoint.
- The socket refuses a connection that is not a unix socket's, and a
  message 2 that does not split ends its session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… home

The authorization server for MCP clients, after the MCP authorization spec,
revision 2026-07-28, with the issuer at https://cloud.ftw.energy:

- OAuth 2.1, authorization code with PKCE S256 only; every authorization
  response, errors too, carries iss (RFC 9207), and the metadata says so
  (RFC 8414).
- Protected resource metadata for https://cloud.ftw.energy/mcp (RFC 9728),
  at the path-inserted and the root well-known URIs.
- resource (RFC 8707): only the MCP resource, at authorize and token; each
  token records its audience and /mcp checks it.
- Client ID metadata documents, fetched behind a fence: https on 443 to a
  DNS name, every resolved address public (checked again as the socket
  connects), no proxy, no redirects, 16 KiB, 5 s, eight at once, cached by
  Cache-Control within a minute and a day, failures for a minute. No
  shared secret may sit in a public document.
- Dynamic registration kept for ChatGPT and older clients: public, or
  client_secret_post/basic with the secret stored as a hash.
- Access tokens for 10 minutes; refresh tokens rotate within a family, and
  a reused one revokes the family; a reused code revokes what it got.
  Tokens, codes and request ids are kept as SHA-256 only.
- 401 and 403 with WWW-Authenticate: resource_metadata, scope, and
  insufficient_scope for step-up.

Consent has no account and no password. /oauth/authorize asks ftwkeysd for
an agent key, then shows the client's name (checked for a metadata
document, marked unchecked for a registration), the host the browser goes
back to (with a warning for loopback), the rights in plain words, a short
code, a QR and https://app.ftw.energy/a/<request>. The app reads
/oauth/requests/<id>, the box's page /oauth/requests?code=. The page waits
on a server-sent event. When the box's signed roster lists the agent's key
as a device that may open a session, the request is approved, the key is
kept, and only the browser that started it gets a fresh code. Denied and
expired requests return access_denied and the key is forgotten.

A token maps to (shard, home, agent id). The subject has a kind, so a
staff principal fits later without changing what an agent's token is. A
roster that drops an agent, or the end the box set for it, deletes its
tokens and its key and tells the MCP side.

ftwcloudd mounts it with -keys-socket; state in oauth.db beside the
registry. No route reads a home (TestOAuthRoutesReadNoHome).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cloud-scale's shard seam (#17) names shards by string, so
the subject carries the shard's name, not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A box sends its roster when it changes, so an approval whose key the keys
part could not keep at once is tried again every 5 s for two minutes,
rather than waiting for a roster that may not come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srcfl/ftw#1642 (pinned at eeb3a6aa until it merges) defines agents. This
follows it:

- Scopes are the registry's constants. An agent row is approved only as
  the roster lists it: role agent, a tunnel, its `agent` grant for the
  client the offer named, and an end still ahead. Support and release rows
  are not this door's.
- The grant is the roster's: scopes, ops, caps and end, refreshed with
  every roster, so an owner who narrows, widens or renews a grant changes
  the token at once. The service stops admitting an agent at its end by
  its own clock.
- The consent page builds the grant offer (key, role, client, clientId,
  scopes, the ops those scopes open, proposed caps, 30 days, the code; no
  home, since the owner picks it), has the keys part sign it, and puts it
  in the link's fragment and the QR. /oauth/requests?code= returns it
  once. /.well-known/ftw-offer-key serves the key the app pins.
- verified means a client FTW Cloud knows, by its document's host; any
  other document client is shown by its host.
- Tokens name their home, ftwat_<home>.<secret>, for shard routing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s without tun

srcfl/ftw#1642 at b7d1cc80 (pinned until it merges):

- The offer is protocol.GrantOffer with clientHost (the metadata
  document's host, or a registered client's redirect host), verified and
  exp (the request's end, at most ten minutes), sent as
  protocol.SignedOffer {o, kid, sig}; protocol.OpenOffer opens it with the
  key pinned by keys.OfferKid. Only loadpoint.hold is a hold op now.
- Agent rows carry tun false: approval needs the agent grant, the role,
  the client and an end ahead, not a tunnel.
- The code lookup answers any origin, as the box's page fetches it from
  the owner's browser; the offer key's endpoint names its kid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rKid lands

srcfl/ftw#1642 now derives an offer key's kid the same way keys.OfferKid
does, and Core pins offer keys as a list of kid:pub. A test holds the two
derivations equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #19 (N3, N4):

- Authenticate grants the intersection of the token's own scopes and the
  agent's in the newest roster, so a narrowed refresh stays narrow and an
  owner's widening needs a new consent.
- A bearer token must have this server's shape (prefix, home, dot, 43
  characters of base64url) before the store is asked.
- The token, revocation and registration endpoints give a client 10 s to
  send its body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…direct, exact verified clients

1. Only the newest roster per home waits to be applied, so nothing drops
   and nothing old replays. A retry after the keys part was away runs the
   approval pass alone, on the newest roster.
2. A purge reaches the authorization server (uplink.OnPurge ->
   DropHome): the home's agents, their tokens and keys, and its consents
   go. A roster from before the purge is not applied after it.
3. A client FTW Cloud does not know gets an error page with a link back
   to its host, never a 302 before the owner acts (RFC 9700, 4.11.2).
4. Verified is an exact list of client_id URLs. Claude's is on it, read at
   its URL on 15 Oct 2026; ChatGPT's and Grok's documents were not found.
5. A key listed at a second home is revoked everywhere and forgotten.
   Codes are eight characters, drawn evenly. Lookups have a bucket per
   address. The request id moves into the link's fragment.
6. Per-address buckets beside the global ones, taken before the client's
   document is fetched. CIMD: a document is kept at least a minute,
   failures have a small cache of their own, and fetches are capped per
   host. Only the cookie holder keeps an event stream, two at most, under
   a global cap. Registrations with no agent go after a day.
7. The store admits an agent's, support's or the release check's row
   neither as a phone nor by a token.

And: a key to forget stays queued, across restarts, until the keys part
has forgotten it; Keep and Forget get 5 s. redirect_uri is required at the
token endpoint when authorize named one. A code used again after the sweep
still revokes. A refresh retried within 5 s works once more. Tokens carry
their home sealed with a fresh nonce, so no client sees an id that links
its tokens. Agents are unique by their full key. Support's client id is
not a metadata client. HSTS on the pages. ::/96 is blocked.

Claude's real document lists the JWT bearer grant, which the old check
refused: a client may list grants this server does not offer, as long as
it uses the code grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCP server, Streamable HTTP at https://cloud.ftw.energy/mcp.

Transport. Revision 2026-07-28: stateless, version and client
capabilities in each request's _meta, the mirrored MCP-Protocol-Version,
Mcp-Method and Mcp-Name headers checked against the body (400 -32020, the
Base64 form decoded), 400 -32022 with the supported versions, 404 -32601,
server/discover, 202 for notifications, 405 for GET and DELETE. Clients of
2025-03-26 to 2025-11-25 that start with initialize are answered too, with
no session id and no stream. Origin is checked; batches are refused.

Auth. Every request needs an access token from package oauth, for this
resource; 401 and 403 carry WWW-Authenticate, and a tool whose scope the
token lacks answers 403 insufficient_scope for step-up. A token for
another shard gets 421. The token never leaves the service.

Read tools, with typed arguments that refuse unknown fields: homes.list,
status, plan.get, plan.explain, history.query, health.get, events.list,
logs.tail, drivers.status, config.get, diag.run. Each maps onto the box's
own API through api.req, or onto the box's hello_ok. Results are data: a
note says so in each, and control and formatting characters are removed
from every string, so a device name or log line cannot hide text.

The path to the box. A call opens or reuses the agent's own Noise session:
admission by store.For with the agent's key (the box's signed roster must
list it), message 1 from ftwkeysd, carried as tun by the door, one forward;
message 2 back the same way. hello goes without waiting. One warm session
per agent, closed after 60 s idle, at once when the owner revokes, and the
box hears it end. The box serves one api.req per session, so calls on one
home queue, four at most; 256 run at once per process; 1,024 sessions per
process.

The box's Noise key comes from the box with the agent contract; until then
ftwcloudd knows none and every home says it takes no assistants yet. The
tests run the whole path against a stub box on the real /v1/box: consent
through a signed roster, reads, refusal at another home, revocation at the
box and by roster, expiry at both ends, the transport in both eras,
step-up, bounded fan-out and the idle close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 12 commits October 15, 2026 23:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under -race the calls arrive spread out, so how many get a turn varies.
That the box never has two at once, that some are turned away and that
none are lost does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The door drops tun past a home's limits (240 messages, then 60 a second,
each way). Every answer is at least three messages from the box, so about
80 calls in a row lost one, and that call waited out its 15 s. The home's
phones share the same limits.

Agents' calls on a home now take from a budget of a quarter of those
limits: 20 calls in a row, then 4 a second, and a quarter of the bytes,
counted by what each answer brought back. A call waits up to 3 s for its
turn, then is told to slow down. TestManyCallsLoseNothing runs past the
burst with nothing dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off the phone path

Following the agent contract (srcfl/ftw#1642, docs/agents.md):

- The door: store.Agent is a principal for sessions the keys part opens.
  An agent row (a roster Device with `agent`) is admitted only as Agent,
  never as a phone (so never on /v1/app or /v1/app/once) and never by a
  token; a phone's row is never an Agent. The store stops admitting an
  agent at agent.expiresAtMs by its own clock, without waiting for a
  roster, and gives an agent nothing from the readable copy.
- The reads: the box refuses api.req to agents, so each read tool now asks
  one of the box's reads for agents: live.get, plan.get (+ price.get for
  plan.explain), hist.query (tiles unpacked to points, null for MISSING),
  health.get, events.get, logs.get (level and lines), config.get (section),
  diag.run (clock, link, meter, planner, driver with its src), drivers.get.
  Answers pass through as data, CBOR to JSON's shapes, bytes as base64url,
  every string cleaned. An answer without an id goes to the one call in
  flight.
- Pacing: 15 calls in a row, then 0.75 a second per home, so no rolling
  minute holds more than the box's 60 reads, and a quarter of the door's
  bytes.
- Words for the new refusals: E_GRANT_EXPIRED, E_OP_NOT_GRANTED,
  E_CAP_EXCEEDED with the cap and its max, E_RATE_LIMITED with the wait,
  E_NEEDS_OWNER, E_NOT_FOUND, E_RANGE_TOO_LARGE, and a box without a clock.
  session.terminate "expired" says the access ran out.

live.get and drivers.get are named here until go/protocol names them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It counted the answer's send too, and hello, so a request that arrived as
the previous answer left looked like two at once under -race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its roster

The agent contract puts the box's Noise static key in the roster it signs
(`noise`, forwarded to srcfl/ftw#1642). RosterBoxKey reads it from the
newest roster the registry holds, checked again with the home's key, so the
service opens a session only to a key the box itself named. Today's uplink
keeps unknown roster fields, so this works as soon as a box sends it; a box
that names none takes no agents yet and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tunnel

srcfl/ftw#1642 at b7d1cc80:

- The store admits an agent row, which now has tun false and empty copy
  scopes, only as store.Agent; a phone row still needs a tunnel or the
  copy, and never passes as an agent.
- RosterBoxKey reads the typed cloudwire.Roster.Noise.
- Each read tool's scope is the registry's (protocol.RegistryReads).
  chargers.list (loadpoints.get) and cars.list (cars.get) are new;
  events.list and logs.tail take before and after cursors and say whether
  more remain. The box names chargers and cars by its agent ids, which the
  control tools take back.
- An answer comes back only by its request's id; agent.decided, pushed to
  an open session, answers no call.
- E_CLOCK_SKEW in words; E_NEEDS_OWNER says whether a durable op belongs
  in goal.request or an owner's hold holds the charger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd limits

From the review of #20:

- S3: what an open acquires (store session, keys session, sid) is set
  under the session's lock and only while it is not closing; a Drop or an
  eviction during an open releases what exists, and the open releases
  what it gets after. The reader takes its session and sid as arguments.
  TestADropDuringOpenLeaksNothing holds it under -race.
- S2: hello carries an id. A box without a clock refuses it with
  E_UNAVAILABLE {subsystem: clock}; the refusal now ends the wait for
  hello at once and comes back in words.
- S4: tool names use underscores (homes_list, plan_get, ...), which every
  model API accepts.
- S6: E_UNKNOWN_OP {arg: id} is an id the box did not give; E_NEEDS_OWNER
  on a hold may be another assistant's; session.terminate says revoked,
  expired, taken over or restarting.
- S7: clean also removes private-use characters, variation selectors, the
  combining grapheme joiner and the fillers that show as nothing.
- S8: an MCP-Protocol-Version this server does not speak gets 400 on the
  initialize-based path too.
- N4: each request gets 10 s to send its body, and each agent 30 requests,
  then 10 a second (429).
- N9: a box's error code reaches a model only in the registry's shape.
- N10: agents' sessions have their own budget at each home, apart from
  the phones' 16.
- N11: history returns no more points per series than asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns drain for a restart

- An assistant's client follows no wrong-shard answer, so /mcp stays on
  cloud.ftw.energy: a token's route names its home, and a request for a
  home another instance holds goes there whole (Server.Elsewhere) before
  this instance reads it. No more 421.
- The pool is a drainer as #17 has them: Sockets counts the warm agent
  sessions in the restart's spread, Drain closes each with the box hearing
  it, and turns calls after it away in words.
- The store's agent-row rule moves into admit, since an agent row is
  admitted as Agent and as nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner

Following the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md):

- ev.charge_now sends loadpoint.hold, the one hold op open to agents, or
  its clear with stop: only when the grant's allowlist names it, with the
  arguments protocol.RegistryAgentArgs allows an agent (id, power_w up to
  maxW, hold_s from 1 to holdS, phase_mode, clear), each of its kind and
  range, checked here before the box and never loosened. hold_s is never 0,
  the operator's endless hold; the service never renews a hold by itself,
  and an owner's own hold wins (E_NEEDS_OWNER, in words). Each cmd has a
  fresh UUIDv7, 30 s of validity in the box's uptime, and the control
  revision the box last named; a stale one is learnt from E_CONFLICT and
  tried once more. battery.hold is gone: the contract no longer opens it
  to agents.
- goal.request sends agent.request {op, args, why} for site.mode.set,
  planner.prefs.set (safety_k 0 to 2, battery_export), loadpoint.surplus_
  only.set and loadpoint.soc.set, held to the same registry rules, with
  the assistant's own reason; the box keeps it until an owner confirms it
  with Face ID. requests.list (agent.list) shows the agent's own grant and
  how each request ended.
- Arguments are typed and refuse unknown fields, so text a model read in a
  log cannot become an instruction to a home. The report says only what
  the box confirmed.

The official MCP inspector (2.3.0, CLI) lists all 16 tools and calls two
through the whole path in TestInspectorSmoke, which runs when
MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with
initialize, so it exercises the earlier-era path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ows the revision

From the review of #21:

- B1: ev_charge_now without power_w paused the car and said it worked,
  since the box reads a missing power as 0. Charging now needs power_w of
  at least 1 (the schema says so too); stop is the way to release.
- S1: the control revision comes from the chargers' answer, read before
  the first command when this session has not seen one, so the first
  command no longer spends the grant's rate on a conflict.
- S2: a box without a clock refuses control at once, in words (hello's
  refusal now reaches the command; see #20).
- S5: ev_charge_now carries destructiveHint.
- S6: stop where this assistant holds nothing says so (E_NOT_FOUND).
- N5: a hold without minutes lasts the default 15, shortened to holdS.
- N6: a command the box refused is a tool error (isError), in words.
- N15: a client's retry of the same call (same JSON-RPC id, op and
  arguments) within a minute reuses the command id, so the box acts once;
  a renewal later is a new command.
- Tool names use underscores (ev_charge_now, goal_request, requests_list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once a cmd is sent, a session that ends (a restart's drain, a reconnect,
the box) or a call that runs out loses the answer, not the command. The
tool says so: state "unknown", "Unknown, not failed", and check status
before trying again. Never "failed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.go: keep both the keys/MCP/OAuth flags and the shard, metrics and
restart-spread flags; push keeps its own context past the signal, auth and
agents stop with the signal. go/protocol pinned to Core master f024251.
The pacing test reads the door's drops through uplink.TunDropped, now that
#17 counts drops in the metrics set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg changed the base branch from feat/mcp-read to main October 15, 2026 21:24
frahlg and others added 2 commits October 15, 2026 23:24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg frahlg closed this Oct 15, 2026
@frahlg frahlg reopened this Oct 15, 2026
@frahlg
frahlg merged commit 2e7afbd into main Oct 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant