Skip to content

feat(mcp): assistants read their home at /mcp - #20

Closed
frahlg wants to merge 10 commits into
feat/mcp-oauthfrom
feat/mcp-read
Closed

frahlg wants to merge 10 commits into
feat/mcp-oauthfrom
feat/mcp-read

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Third of four stacked PRs. It is based on #19 (OAuth), which is based on #18 (keys). It is built to the agent contract, srcfl/ftw#1642 (merged; #18 pins it at bf5bf50); Core's enforcement is srcfl/ftw#1646.

What this adds

The MCP server: Streamable HTTP at https://cloud.ftw.energy/mcp.

Transport.

  • Revision 2026-07-28, stateless. Each request carries its version and client capabilities in _meta. The mirrored MCP-Protocol-Version, Mcp-Method and Mcp-Name headers are checked against the body, with the Base64 form decoded.
  • The errors the spec names. A header that disagrees gets 400 -32020; an unsupported version 400 -32022 with the supported list; an unknown method 404 -32601. server/discover answers, a notification gets 202, and GET and DELETE get 405.
  • Earlier clients too. Clients of 2025-03-26 to 2025-11-25 that start with initialize are answered, with no session id and no stream. A version header this server does not speak gets 400 there too.
  • Origin is checked, and batches are refused.

Auth.

  • A token on every request. It must be 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, so the client can step up.
  • Shards. An assistant's client follows no wrong-shard answer, so /mcp stays on cloud.ftw.energy and never answers 421. A token's route names its home (feat(oauth): assistants sign in with OAuth, and the owner approves at home #19), and a request for a home another instance holds goes there whole, inside FTW Cloud, before this instance reads it (Server.Elsewhere, which shard.Directory fills once there is more than one shard).
  • No passthrough. The token never leaves the service.

The tools, with typed arguments that refuse unknown fields. Their names fit ^[a-zA-Z0-9_-]{1,64}$, which every model API accepts. Each tool is one of the box's own reads for agents, under the scope protocol.RegistryReads names:

Tool Box read
homes_list the token's home, agent, scopes, ops, caps and end
status live.get
chargers_list loadpoints.get
cars_list cars.get
plan_get (hours) plan.get, trimmed
plan_explain (hours) plan.get and price.get
history_query (range, points) hist.query: tiles unpacked, null for missing, no more points than asked
health_get health.get
events_list (limit, before, after) events.get
logs_tail (lines, level, before, after) logs.get
drivers_status drivers.get
config_get (section) config.get
diag_run (check, src) diag.run
  • The box refuses api.req to agents, so nothing goes through the passthrough.
  • The readable copy is never an agent's. Every read goes through the box, which audits it and enforces revoke.
  • Results are data. Each carries a note that says so. Characters that can hide text are removed from every string: controls, format and private-use characters, variation selectors, the combining grapheme joiner and blank fillers. This narrows what text can hide; it does not promise that none can mislead.

The door.

  • Agent rows only on the keys part's sessions. An agent row (a roster Device with agent, tun: false, empty copy scopes) is admitted only as store.Agent, for a session the keys part opens, and never after agent.expiresAtMs by the store's own clock. An agent's, support's or the release check's row is never admitted as a phone (so never on /v1/app or /v1/app/once) or by a token.
  • Budgets. Agents' sessions have their own budget at each home (16), apart from the phones' 16.
  • The box's Noise key comes from Roster.Noise in the newest signed roster, checked again with the home's key. A box that names none takes no assistants yet, and says so.

The path to the box: one forward.

  1. Admission. store.For(Agent) admits the call.
  2. Handshake. ftwkeysd writes message 1, and the door carries it as tun. Message 2 comes back the same way.
  3. hello, with an id. A box without a clock refuses it, and that refusal comes back at once, in words.
  4. A warm session. One per agent row, closed after 60 s idle, at once when the owner revokes or the box purges its home, and the box hears it end. A Drop or an eviction during an open releases what the open has acquired, and the open releases what it gets after.
  5. Bounded calls. Answers come back only by their request's id; agent.decided answers no call. Calls on one home queue, four at most. A process runs 256 calls and holds 1,024 sessions. Each agent may send 30 requests, then 10 a second (429). Each request gets 10 s to send its body.
  6. Pacing. Agents' calls on a home are paced to 15 in a row, then 0.75 a second, so no rolling minute holds more than the box's 60 reads; and to a quarter of the door's bytes, which the home's phones share.

Refusals in words. Each names its code, and only in the registry's shape:

  • E_SCOPE_DENIED, E_OP_NOT_GRANTED, E_GRANT_REVOKED and E_GRANT_EXPIRED;
  • E_CAP_EXCEEDED with the cap, and E_RATE_LIMITED with the wait;
  • E_NEEDS_OWNER: a durable op, or another hold;
  • E_UNKNOWN_OP on an id the box did not give;
  • E_CLOCK_SKEW, E_NOT_FOUND, and a box without a clock.

session.terminate comes back as revoked, expired, taken over or restarting.

What the tests prove

internal/mcp runs against a stub box that stands in for Core as #1642 describes. It dials the real /v1/box and signs a roster with agent rows (no tunnel) and noise. It checks every handshake, grant, expiry and read scope, and refuses api.req.

  • An assistant reads its home after the whole consent (register, PKCE, the signed roster, the page's event, code for token). Every tool reaches its own box message, cursors included. Injected text arrives as data, with the hidden characters gone. The box sees one handshake, never api.req, and never the token.
  • A token for one home is refused at another. The same agent key aimed at another home is refused by that home's roster before a byte leaves.
  • Revoke at the box fails the next call (E_GRANT_REVOKED, then no handshake). A roster without the agent gives 401 and leaves no key.
  • An expired agent fails at the box (E_GRANT_EXPIRED, terminated "expired") and at the door, at expiresAtMs.
  • A box without noise takes no agents, and the key is believed only from the box's own signed roster.
  • A box without a clock says so at once, for reads and for hello.
  • A Drop during an open leaks nothing: no keys session, no store slot, no data race under -race.
  • A request for another shard goes there whole, body and token, before anything here reads it; a route this instance cannot open is checked here like any token.
  • A restart drains the agent sessions: they count, each closes, the box hears it, and a call after it is turned away in words.
  • The transport works in both eras, with an unsupported version refused on each. Step-up gives 403 naming the scope. The per-agent limit gives 429.
  • Bounded fan-out and pacing. The box never has two requests at once, and the door drops nothing.
  • The idle close works, and the box hears it end.
  • Unit tests: the words for each refusal and termination, hidden characters, tool names, the store's agent admission and its own session budget.

Not in this PR

🤖 Generated with Claude Code

@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:31:31.168473Z 144c44c 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: 144c44c372

ℹ️ 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 cmd/ftwcloudd/main.go
// box's Noise key comes from the box with the agent contract; until a
// box says it, its home takes no agents and the tools say so.
agents := mcp.NewPool(st, up, agentKeys, func(string) ([]byte, bool) { return nil, false }, 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 👍 / 👎.

Comment thread internal/mcp/mcp.go
Comment on lines +378 to +381
out := map[string]any{"jsonrpc": "2.0", "error": e}
if len(id) > 0 {
out["id"] = id
}

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 Include a null ID in request-level JSON-RPC errors

For parse errors, rejected batches, oversized bodies, and other failures where no request ID can be recovered, this omits the id member entirely. JSON-RPC 2.0 requires such error responses to contain "id": null; strict MCP/JSON-RPC clients may reject the response instead of surfacing the intended error. Initialize the response with a null ID and replace it only when a valid ID is available.

Useful? React with 👍 / 👎.

@frahlg
frahlg force-pushed the feat/mcp-read branch 3 times, most recently from d48c811 to 2b1e69f Compare October 15, 2026 20:29
frahlg added a commit that referenced this pull request Oct 15, 2026
…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>
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 10 commits October 15, 2026 23:04
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>
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>
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 added a commit that referenced this pull request Oct 15, 2026
#19, #20, #21)

The MCP stack as one change: ftwkeysd, the one part that can open an agent's session (#18); OAuth for assistants with the owner's approval at home (#19); reads at /mcp (#20); control within the owner's grant, and requests that wait for the owner (#21). Reviewed in two passes; merged with main after #17 (scale), go/protocol pinned to srcfl/ftw master f024251.

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

frahlg commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

Included in #21 (squash 2e7afbd), which carries the whole MCP stack.

@frahlg frahlg closed this Oct 15, 2026
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