Skip to content

Commit f52662c

Browse files
committed
Give the HTTP transport the session identity MCP specifies
The destructive-action confirmation fired on stdio and never over HTTP. The prompt is a question asked between receiving a call and answering it, so it needs a channel bound to the scope that received initialize — and that scope was keyed on the TCP connection, which a plain POST ends and an SSE POST closes after its last event. The capability advertised at initialize was always gone by the tools/call. Key the scope on Mcp-Session-Id instead. initialize mints one and returns it as a header; GET with an SSE Accept opens the standing server-to-client stream; DELETE terminates. The dispatcher needed no change, since it already scoped capabilities and call slots on an opaque connection_id, so per-connection isolation is preserved as-is — a session is just an identity that outlives a socket. Sessions are swept when idle and capped. A client that echoes the id and gives the server somewhere to send the question is now prompted for real, over either the standing stream or an SSE POST's own response stream. One that does neither still proceeds, as a stdio client without elicitation does; that fallback is now documented and pinned rather than being the only behaviour. Also stop draining a request body that was already read. The new 404 and 409 are decided after parsing, as the "body must be UTF-8" 400 always was, and the drain then blocked on bytes that were gone until the read timeout.
1 parent d7ff13f commit f52662c

10 files changed

Lines changed: 1200 additions & 142 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,26 @@ only when documented here with a migration path.
124124
land somewhere else; unset (or any unrecognised value, which says so and
125125
falls back) keeps the existing warn-once-and-move behaviour. The libei path
126126
is absolute at the protocol level and is not affected either way.
127+
- **MCP sessions over HTTP.** `initialize` now mints an `Mcp-Session-Id` and
128+
returns it as a response header. A client that echoes it keeps one
129+
dispatcher scope — the capabilities it advertised, and the slots its
130+
in-flight calls occupy — across every connection it opens, instead of one
131+
scope per TCP connection. `GET /mcp` with `Accept: text/event-stream` and a
132+
valid session id opens the standing server-to-client SSE stream (one per
133+
session; a second gets 409), `DELETE /mcp` with the id terminates the
134+
session, and a server request is answered by `POST`ing an ordinary JSON-RPC
135+
response on any connection. Sessions are swept after ten minutes untouched
136+
and capped at 128. `je_auto_control.utils.mcp_server.http_sessions` holds
137+
the registry.
138+
- **`JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE=1` now works over HTTP** — for a
139+
client that echoes `Mcp-Session-Id` and holds the `GET` stream open. It
140+
previously fired only on stdio: the prompt needs a server-to-client channel
141+
bound to the scope that received `initialize`, and a connection-keyed scope
142+
never survived to the `tools/call`. A client that does neither still cannot
143+
be prompted and its destructive calls still proceed, exactly as for a stdio
144+
client that never advertised `elicitation`; that fallback is documented and
145+
is not a substitute for the bearer token, the `127.0.0.1` bind or
146+
`JE_AUTOCONTROL_MCP_READONLY`.
127147

128148
### Removed
129149

@@ -145,6 +165,15 @@ only when documented here with a migration path.
145165

146166
### Changed
147167

168+
- The MCP HTTP transport answers `GET /mcp` differently. It used to return
169+
`405` with `{"error": "GET stream not supported"}` for every request; it now
170+
serves the session's SSE stream when the request carries
171+
`Accept: text/event-stream` and a valid `Mcp-Session-Id`, and still returns
172+
`405` when the `Accept` header does not ask for a stream. A request — of any
173+
method — carrying an `Mcp-Session-Id` the server does not know is refused
174+
with `404` rather than served under a fresh scope, which is the signal to
175+
re-run `initialize`. `DELETE /mcp` without a session header is still
176+
accepted as a no-op, so clients that never adopt sessions are unaffected.
148177
- The default run-history database is created when it is first written to,
149178
not while `je_auto_control` is being imported. `HistoryStore` opens its
150179
connection (and makes its parent directory) on first use, so merely
@@ -316,6 +345,15 @@ only when documented here with a migration path.
316345

317346
### Fixed
318347

348+
- The MCP HTTP transport no longer tries to drain a request body it has
349+
already read. Any `4xx` decided *after* the body was parsed — the new
350+
unknown-session `404` and duplicate-stream `409`, and the pre-existing
351+
"body must be UTF-8" `400` — called `_drain_body()`, which then blocked
352+
reading bytes that were gone until the 30-second socket timeout, pinning
353+
that worker and logging a `ConnectionAbortedError` traceback when the peer
354+
closed first. The drain is now skipped once the body is consumed, and a
355+
peer that has already vanished ends it quietly instead of raising.
356+
319357
- **A rejected config bundle aborted the rest of the script.** Five
320358
framework errors still inherited `Exception` directly —
321359
`ConfigBundleError`, the USB passthrough `ProtocolError`,

Progress.md

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -98,57 +98,6 @@ wheel,那時把 `windows-11-arm` 加回 `platform-smoke.yml` 的矩陣。
9898
都綠,macOS 本來就是 arm64。所以卡住的只有 Windows
9999
這一個組合。
100100

101-
## MCP 的破壞性動作確認,在 HTTP transport 上一次都不會觸發
102-
103-
`DECIDE` — 要嘛補上 session 身分,要嘛改成 fail closed;兩條都會動到行為
104-
105-
`JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE=1` 的用意是「每個 destructive 工具執行前
106-
先問過人」。這件事在 stdio 上是好的,**在 HTTP transport 上一次都不會觸發**——
107-
而且不是文件原本以為的「initialize 跟 tools/call 落在不同連線時才失效」,是**四種
108-
組合全部失效**。實測(2026-08-20,真的 `HttpMCPServer`,真的 destructive 工具):
109-
110-
| 情境 | 結果 |
111-
| --- | --- |
112-
| 行程內(等同 stdio),同一個 server 物件 | 送出 `elicitation/create`,拒絕就不執行 ✅ |
113-
| HTTP,plain POST,同一條連線 | **不問就執行** |
114-
| HTTP,plain POST,兩條連線 | **不問就執行** |
115-
| HTTP,SSE,同一條連線 | **不問就執行** |
116-
| HTTP,SSE,兩條連線 | **不問就執行** |
117-
118-
原因有兩層,兩層都得處理:
119-
120-
1. `_maybe_confirm_destructive``self._writer is None` 時直接 return。plain POST
121-
`connection_scope` 沒有 writer(一次 request/response,沒有 server→client 通道),
122-
所以這條路連問的能力都沒有。
123-
2. 就算是有 writer 的 SSE,`_dispatch_sse` 會把 `close_connection` 設成 True,
124-
`finish()` 接著呼叫 `forget_connection(id(self))`。而 connection scope 是用
125-
`id(self)`(TCP 連線)當 key,不是用 MCP session,所以 `initialize` 帶進來的
126-
`capabilities`(裡面才有 `elicitation`)在下一個 `tools/call` 一定已經被忘掉,
127-
於是走進「client 沒有 elicitation 能力」那條分支,留一行 INFO log 後**放行**
128-
129-
也就是說,操作者設了這個變數、以為擋住了,實際上什麼都沒擋,而且只有 INFO log。
130-
文件原本寫「gate every destructive tool」,只註明「舊 client 會 fall through」,
131-
沒說 HTTP 上根本不會啟動——**這一半已經修好了**:兩份 `mcp_server_doc.rst` 都加了
132-
warning,說明它目前只在 stdio 有效,HTTP 請改靠 bearer token 與綁 `127.0.0.1`
133-
`test_mcp_http_transport.py::test_destructive_confirm_gate_never_fires_over_http`
134-
把現況釘住(plain POST 與 SSE 兩種),哪天有人補好了,那個測試會當場紅掉,
135-
提醒他同一筆改動要把文件的 warning 跟本節一起改掉。
136-
137-
**還沒決定的是行為要往哪邊走**,兩個選項都不是純加法:
138-
139-
- **(A) fail closed** — 變數開著又問不到人時,直接拒絕執行 destructive 工具。
140-
最貼近操作者的意圖,改動也最小。但會翻掉現有的
141-
`test_destructive_confirmation_skipped_when_client_lacks_capability`,而且會擋掉
142-
今天所有沒有 elicitation 能力的 client(包含 HTTP 上的每一個)。
143-
- **(B) 補上 `Mcp-Session-Id`** — 讓 scope 跟著 MCP session 走而不是跟著 TCP 連線,
144-
這樣 SSE 那條路才有機會真的問出來。這是 MCP 自己的答案,但這個 transport 是
145-
**刻意**做成 sessionless 的(`do_DELETE` 的註解就這麼寫),而 per-connection 隔離
146-
正是先前一次重構的重點,有 `test_r3_mcp_connection_isolation` 在守。要動得連那條
147-
隔離一起重新想,不能只是把 key 換掉。plain POST 就算有了 session 仍然沒有通道,
148-
所以 (B) 落地後,plain POST 還是得靠 (A) 收尾。
149-
150-
重驗方式:把上表跑一次即可,四種 HTTP 組合都應該是「不問就執行」。
151-
152101
## Wayland:剩下的都不是「缺一台機器」
153102

154103
這一項曾經三度寫成「要一台 VM」——先是 portal 交握,再是 ydotool 的絕對移動落點,

WHATS_NEW.md

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -320,34 +320,76 @@ real X server through `xev`, `freebsd_verify.py` pins it on a BSD, and
320320
display. The migration note for anyone who was relying on the magnitude alone
321321
is in `CHANGELOG.md`.
322322

323-
### The Destructive-Action Prompt Was Documented as Covering a Transport It Never Reached
323+
### The Destructive-Action Prompt Reached Only One of the Two Transports
324324

325325
`JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE=1` is documented as gating *every*
326326
destructive MCP tool behind a confirmation prompt, with one stated caveat: the
327327
client has to advertise the `elicitation` capability. Measured against a real
328-
`HttpMCPServer` with a real destructive tool, the gate fires on stdio and
329-
**never fires over HTTP** — not in any of the four combinations of plain POST
328+
`HttpMCPServer` with a real destructive tool, the gate fired on stdio and
329+
**never fired over HTTP** — not in any of the four combinations of plain POST
330330
or SSE, one connection or two.
331331

332-
Two independent reasons, and the second one is why keeping the connection open
333-
does not help. A plain `POST` is one request and one response, so its
334-
connection scope has no server→client channel and the prompt has nothing to
335-
travel down. An SSE `POST` does have one, but `_dispatch_sse` sets
336-
`close_connection`, so the scope keyed on that TCP connection is forgotten
337-
before the next request — and the `elicitation` capability the client
338-
advertised at `initialize` goes with it. The call then takes the "client
339-
cannot be prompted" branch, logs one INFO line, and runs the tool.
340-
341-
An operator who sets that variable on an HTTP-exposed server is getting no
342-
confirmation at all. Both `mcp_server_doc.rst` translations now say so, and
343-
point at the bearer token and the `127.0.0.1` bind as the controls that do
344-
work there. `test_destructive_confirm_gate_never_fires_over_http` pins the
345-
current behaviour for both content types, so whoever closes the gap gets a red
346-
test telling them to update the warning in the same change. Which of the two
347-
fixes to take — refuse when the prompt is impossible, or give the transport
348-
real session identity — is a behaviour change either way and is written up in
349-
`Progress.md`.
350-
332+
Two independent reasons, and the second is why keeping the connection open did
333+
not help. A plain `POST` is one request and one response, so its connection
334+
scope had no server→client channel and the prompt had nothing to travel down.
335+
An SSE `POST` did have one, but `_dispatch_sse` sets `close_connection`, so the
336+
scope keyed on that TCP connection was forgotten before the next request — and
337+
the `elicitation` capability advertised at `initialize` went with it. The call
338+
then took the "client cannot be prompted" branch, logged one INFO line, and ran
339+
the tool. An operator who set that variable on an HTTP-exposed server was
340+
getting no confirmation at all.
341+
342+
**The transport now has the identity MCP actually specifies.** `initialize`
343+
mints an `Mcp-Session-Id` and returns it as a response header; a client that
344+
echoes it keeps one dispatcher scope across every connection it opens. `GET`
345+
with `Accept: text/event-stream` opens the standing server→client stream that
346+
server-initiated traffic belongs on, and answering a server request is an
347+
ordinary `POST` matched back to the waiting call by id. `DELETE` terminates.
348+
The dispatcher itself needed no change — it already scoped capabilities and
349+
active-call slots on an opaque `connection_id`, so a session id simply takes
350+
that slot, and the per-connection isolation guarded by
351+
`test_r3_mcp_connection_isolation` is preserved verbatim: a session is just an
352+
identity that outlives a socket.
353+
354+
So the confirmation now round-trips over HTTP, and the test that proves it uses
355+
four separate connections — one that initialized, one holding the stream, one
356+
carrying the `tools/call`, one carrying the decline — none of which shared a
357+
socket with the handshake. Declining blocks the tool; accepting runs it.
358+
359+
Measuring it turned up a second way through that was worth pinning: an SSE
360+
`POST` carrying a session id needs no standing stream at all, because its own
361+
response stream is already a server-to-client channel. The `elicitation/create`
362+
goes out ahead of the result on the same socket the call arrived on. Both paths
363+
now have a test.
364+
365+
What did *not* change is the fallback: a client that ignores the session header,
366+
or that only ever sends plain JSON `POST`s with no stream open, has given the
367+
server nowhere to ask, and its destructive calls still proceed — exactly as they
368+
do for a stdio client that never advertised `elicitation`. That is now a
369+
documented boundary with a test on each side of it rather than an accident — but
370+
it is still a boundary, so the bearer token, the `127.0.0.1` bind and
371+
`JE_AUTOCONTROL_MCP_READONLY` remain the controls that do not depend on the
372+
client behaving.
373+
374+
Sessions are bounded in both directions: swept after ten minutes untouched, and
375+
the registry evicts the least recently seen once it holds 128. Dropping a
376+
session — however it goes — releases the dispatcher state held under its id,
377+
which is the same release a closing socket used to perform, moved to the
378+
identity that actually owns that state. Because every `initialize` mints a
379+
session, including for the many clients that ignore the header and never come
380+
back, evicting one that was never used after its handshake is logged as routine;
381+
the warning is saved for evicting a session someone was holding, which is the
382+
one that means the cap is too low.
383+
384+
The new refusals also exposed an old assumption in the transport. Every `4xx`
385+
runs `_drain_body()` first, so the client can read the response before the
386+
socket closes — but the new unknown-session `404` and duplicate-stream `409` are
387+
decided *after* the body has been parsed, and so was the pre-existing "body must
388+
be UTF-8" `400`. The drain then went looking for bytes that were already gone
389+
and blocked until the thirty-second read timeout, pinning that worker and
390+
printing a `ConnectionAbortedError` traceback whenever the peer closed first.
391+
It now skips the drain once the body is consumed, and treats a peer that has
392+
already vanished as nothing left to be courteous to.
351393

352394
## What's new (2026-08-19)
353395

architecture_explore.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、
1919

2020
| 指標 | 數值 |
2121
| --- | ---: |
22-
| Python 模組總數(含周邊子專案) | 1,028 |
23-
| 程式碼總行數 | 139,628 |
22+
| Python 模組總數(含周邊子專案) | 1,029 |
23+
| 程式碼總行數 | 140,053 |
2424
| `je_auto_control/utils/` 子套件數 | 310 |
2525
| `AC_*` 動作指令數(`known_commands()` 實測) | 773 |
2626
| 套件門面 `__all__` 公開名稱數 | 1,238 |
@@ -488,7 +488,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。
488488

489489
### 5.4.9 AI / Agent / LLM
490490

491-
> 13 個套件、約 20,185 行。
491+
> 13 個套件、約 20,610 行。
492492
493493
| 模組 | 行數 | 職責 |
494494
| --- | ---: | --- |
@@ -501,7 +501,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。
501501
| `utils/cua_action/` | 127 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`|
502502
| `utils/llm/` | 357 | 自然語言 → action list 規劃器 + Anthropic/null 後端 |
503503
| `utils/mcp_registry/` | 92 | MCP registry `server.json` 資訊清單產生(可被發現) |
504-
| `utils/mcp_server/` | 16,898 | **無頭 MCP 伺服器**(16K LOC,預設註冊 676 個工具=657 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 |
504+
| `utils/mcp_server/` | 17,323 | **無頭 MCP 伺服器**(16K LOC,預設註冊 676 個工具=657 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 |
505505
| `utils/tool_use_schema/` | 180 |`AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema |
506506
| `utils/trajectory_eval/` | 106 | agent 軌跡評估:依評分規準為一次執行打分 |
507507
| `utils/vision/` | 448 | VLM 元素定位器(依描述找元素)+ Anthropic/OpenAI/null 後端 |
@@ -700,14 +700,15 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。
700700
| `action_schema.py` | 128 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 |
701701
| `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 |
702702

703-
#### `utils/mcp_server/`16,898 行,676 個工具)— 最大子系統
703+
#### `utils/mcp_server/`17,323 行,676 個工具)— 最大子系統
704704

705705
| 檔案 | 行數 | 職責 |
706706
| --- | ---: | --- |
707707
| `tools/_factories.py` | 8,739 | 工具工廠:每個函式回傳一個領域的 `MCPTool` 清單(把 `AC_*` 能力包成 MCP 工具)。 |
708708
| `tools/_handlers.py` | 4,651 | 把 MCP 工具呼叫橋接到 AutoControl 無頭 API 的 adapter。 |
709709
| `server.py` | 713 | JSON-RPC 2.0 over stdio 的最小 MCP 伺服器:連線範圍狀態、行內/併發分派、工具與 resource/prompt 處理器。 |
710-
| `http_transport.py` | 323 | MCP 的 HTTP 傳輸。 |
710+
| `http_transport.py` | 514 | MCP 的 HTTP 傳輸。 |
711+
| `http_sessions.py` | 234 | MCP 的 HTTP 傳輸用的 session 身分:`Mcp-Session-Id` 註冊表,以及每個 session 那條常駐的 server→client SSE 串流。 |
711712
| `_client_requests.py` | 217 | 伺服器主動送出的請求:`roots/list``elicitation/create``sampling/createMessage`,對應表與回應路由,以及破壞性工具的確認交握。 |
712713
| `_protocol.py` | 165 | JSON-RPC 線路格式:版本與識別常數、`_MCPError`、決定失敗工具行為的錯誤 tuple、envelope 產生器、工具回傳值轉 `content` 區塊。不碰伺服器狀態。 |
713714
| `resources.py` | 303 | MCP resource 提供者。 |
@@ -1019,7 +1020,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。
10191020
| 層/子系統 | 檔案數 | 行數 |
10201021
| --- | ---: | ---: |
10211022
| `gui/` | 89 | 26,542 |
1022-
| `utils/mcp_server/` | 20 | 16,898 |
1023+
| `utils/mcp_server/` | 21 | 17,323 |
10231024
| `utils/remote_desktop/` | 56 | 11,840 |
10241025
| `utils/executor/` | 6 | 9,075 |
10251026
| `utils/usb/` | 17 | 4,250 |
@@ -1039,5 +1040,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。
10391040
| `autocontrol-lsp/` | 8 | 744 |
10401041
| `utils/hotkey/` | 7 | 727 |
10411042
| 其餘模組(約 286 個 `utils/` 子套件 + `android/``ios/`/周邊小工具) | 690 | 50,425 |
1042-
| **總計** | **1,022** | **139,563** |
1043+
| **總計** | **1,023** | **139,988** |
10431044

0 commit comments

Comments
 (0)