authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-11 22:15:03-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log29150b57367062e4382de7fb3ff0c952b7c38c4e
tree8c1e83dc0eca4bf6cf5b0a3044fa8fd7aea706bf
parent07c6d70b91d2d2a81e9ccee0a9b1742b60d59eb2

Add shared MCP preview automation


46 files changed, 3973 insertions(+), 49 deletions(-)

.plans/shared-http-mcp-server-with-preview-automation-revised.md created+520
......@@ -0,0 +1,520 @@
1# Shared HTTP MCP Server with Preview Automation
2
3## Summary
4
5Embed one reusable HTTP MCP server in each T3 environment server. Every agent session connects to that shared MCP endpoint using a session-scoped bearer token.
6
7Preview browser automation is the first MCP toolkit, not the purpose or boundary of the MCP server. Future T3 toolkits register with the same server.
8
9Architecture:
10
11`agent session` -> `shared T3 HTTP MCP server` -> `tool dispatcher` -> `preview broker` -> `focused desktop client` -> `Electron webview via CDP`
12
13No per-thread MCP process, stdio transport, headless browser, or automatic remote port forwarding.
14
15## Process Model
16
17Each `apps/server` process owns exactly one MCP server instance.
18
19- MCP transport: HTTP.
20- MCP endpoint: `/mcp`.
21- MCP lifetime: environment server lifetime.
22- Agent sessions create MCP protocol sessions/connections, not OS processes.
23- Toolkit registration happens once during server startup.
24- Provider session termination revokes only its scoped credential.
25
26Implement the endpoint with:
27
28```ts
29McpServer.layerHttp({
30 name: "T3 Code",
31 version,
32 path: "/mcp",
33});
34```
35
36Use the API from:
37
38`effect/unstable/ai/McpServer`
39
40Reference source:
41
42`.repos/effect-smol/packages/effect/src/unstable/ai/McpServer.ts`
43
44Do not implement MCP framing, initialization, session management, or JSON-RPC manually.
45
46## Invocation Identity
47
48MCP `tools/call` does not include T3 thread identity. Bind identity to the MCP connection through authentication.
49
50When starting or resuming a provider session, issue an opaque bearer token associated internally with:
51
52```ts
53interface McpInvocationScope {
54 environmentId: EnvironmentId;
55 threadId: ThreadId;
56 providerSessionId: string;
57 providerInstanceId: ProviderInstanceId;
58 allowedCapabilities: ReadonlySet<string>;
59 issuedAt: string;
60 expiresAt: string;
61}
62```
63
64Provider MCP configuration:
65
66```ts
67{
68 type: "http",
69 name: "t3",
70 url: `${environmentHttpBaseUrl}/mcp`,
71 headers: [
72 {
73 name: "Authorization",
74 value: `Bearer ${token}`,
75 },
76 ],
77}
78```
79
80Requirements:
81
82- Agents never receive or pass `threadId` as a tool argument.
83- Tool handlers obtain `McpInvocationScope` from request authentication middleware.
84- Tokens are cryptographically random opaque values.
85- Store only a hash of each token server-side.
86- Revoke tokens when the provider session stops.
87- Expire tokens after inactivity and at a fixed maximum lifetime.
88- Server restart invalidates all tokens.
89- Resuming a provider session issues a fresh token.
90
91## General MCP Architecture
92
93Add server modules such as:
94
95```text
96apps/server/src/mcp/
97 Services/
98 McpSessionRegistry.ts
99 McpInvocationContext.ts
100 Layers/
101 McpSessionRegistry.ts
102 McpHttpServer.ts
103 toolkits/
104 preview/
105 tools.ts
106 handlers.ts
107 layer.ts
108```
109
110### Toolkit Registration
111
112Define capabilities with Effect AI:
113
114```ts
115import { McpServer, Tool, Toolkit } from "effect/unstable/ai";
116```
117
118Each capability family owns:
119
120- `Tool.make` definitions
121- a `Toolkit.make` collection
122- handler services/layers
123- an MCP registration layer
124
125Server startup merges all registration layers:
126
127```ts
128const T3McpToolkits = Layer.mergeAll(
129 PreviewToolkitRegistration,
130 // Future toolkit registrations
131);
132```
133
134Future filesystem, terminal, source-control, or environment tools must not require changes to MCP transport or authentication.
135
136### Naming
137
138Use stable capability-prefixed names:
139
140- `preview_status`
141- `preview_open`
142- `preview_navigate`
143- `preview_snapshot`
144- `preview_click`
145- `preview_type`
146- `preview_press`
147- `preview_scroll`
148- `preview_evaluate`
149- `preview_wait_for`
150
151## MCP Authentication
152
153Integrate bearer authentication into the HTTP MCP route before MCP request handling.
154
155Authentication flow:
156
1571. Read `Authorization: Bearer <token>`.
1582. Hash token and resolve it through `McpSessionRegistry`.
1593. Verify expiration and provider-session liveness.
1604. Provide `McpInvocationContext` to MCP toolkit handlers.
1615. Reject invalid credentials without invoking MCP tools.
1626. Update token activity timestamp after authenticated requests.
163
164Capability authorization occurs inside handlers or common middleware:
165
166```ts
167yield* McpInvocationContext.requireCapability("preview");
168```
169
170## Preview Automation Contracts
171
172Add `packages/contracts/src/previewAutomation.ts`.
173
174Define schemas for:
175
176- preview status
177- opening/showing preview
178- navigation
179- page snapshot
180- selector or coordinate click
181- text entry
182- key press
183- scrolling
184- JavaScript evaluation
185- waiting for selector, text, or URL
186
187Define tagged errors:
188
189- `PreviewAutomationUnavailableError`
190- `PreviewAutomationNoFocusedOwnerError`
191- `PreviewAutomationUnsupportedClientError`
192- `PreviewAutomationTabNotFoundError`
193- `PreviewAutomationTimeoutError`
194- `PreviewAutomationExecutionError`
195- `PreviewAutomationInvalidSelectorError`
196- `PreviewAutomationResultTooLargeError`
197
198## Preview Toolkit
199
200Implement preview tools as an independent Effect AI toolkit.
201
202Apply annotations:
203
204- `preview_status` and `preview_snapshot`: read-only
205- `preview_status`: idempotent
206- navigation and page interaction tools: open-world
207- browser operations: non-destructive
208- all tools: human-readable title and precise description
209
210### `preview_open`
211
212Input:
213
214```ts
215{
216 url?: string;
217 show?: boolean;
218 reuseExistingTab?: boolean;
219}
220```
221
222Defaults:
223
224- `show: true`
225- `reuseExistingTab: true`
226
227Behavior:
228
229- Show the preview panel for the scoped thread.
230- Reuse its active preview tab when available.
231- Create and mount a tab otherwise.
232- Navigate when `url` is supplied.
233- Wait until the webview has registered before returning.
234
235### `preview_snapshot`
236
237Return:
238
239- current URL, title, loading state
240- bounded visible text
241- up to 200 interactive elements
242- accessibility tree
243- PNG screenshot scaled to a maximum width of 1280 pixels
244
245Expose the screenshot as MCP image content and metadata as structured content.
246
247### Browser Controls
248
249- `preview_click`: selector or viewport coordinates
250- `preview_type`: optionally focus selector and clear existing value
251- `preview_press`: common keys and modifiers
252- `preview_scroll`: viewport or selector target
253- `preview_evaluate`: execute bounded JavaScript
254- `preview_wait_for`: selector, visible text, or URL substring
255- `preview_navigate`: navigate and wait for selected readiness condition
256
257Default operation timeout: 15 seconds.
258
259Maximum serialized evaluation result: 64 KB.
260
261Maximum visible text: 20 KB.
262
263## Preview Broker
264
265Add `PreviewAutomationBroker` to `apps/server`.
266
267Responsibilities:
268
269- Track automation-capable desktop clients.
270- Track preview ownership by environment and thread.
271- Route operations to the correct desktop client.
272- Correlate requests and responses.
273- Enforce timeouts.
274- Fail pending calls when clients disconnect.
275
276Owner state:
277
278```ts
279interface PreviewAutomationOwner {
280 clientId: string;
281 environmentId: EnvironmentId;
282 threadId: ThreadId;
283 tabId: PreviewTabId | null;
284 visible: boolean;
285 supportsAutomation: boolean;
286 focusedAt: string;
287}
288```
289
290Routing policy:
291
292- Use the most recently focused Electron window displaying the scoped thread.
293- Never accept environment or thread overrides from tool arguments.
294- Return `PreviewAutomationNoFocusedOwnerError` when no valid owner exists.
295- Do not switch the UI to a different thread automatically.
296
297## Server-to-Desktop Protocol
298
299Add WS RPCs:
300
301- `previewAutomation.connect`
302- `previewAutomation.respond`
303- `previewAutomation.reportOwner`
304- `previewAutomation.clearOwner`
305
306`connect` is a long-lived stream from the environment server to the desktop client.
307
308Request:
309
310```ts
311{
312 requestId: string;
313 threadId: ThreadId;
314 tabId?: PreviewTabId;
315 operation: PreviewAutomationOperation;
316 input: unknown;
317 timeoutMs: number;
318}
319```
320
321Response:
322
323```ts
324{
325 requestId: string;
326 ok: boolean;
327 result?: unknown;
328 error?: {
329 _tag: string;
330 message: string;
331 detail?: unknown;
332 };
333}
334```
335
336## Desktop Automation
337
338Extend `PreviewViewManager` using Electron `webContents.debugger`.
339
340Use CDP domains:
341
342- `Runtime`
343- `DOM`
344- `Page`
345- `Accessibility`
346- `Input`
347
348Use `webContents.capturePage()` for screenshots.
349
350Create a scoped CDP helper that:
351
3521. Resolves the tab’s webContents.
3532. Attaches lazily.
3543. Enables required domains.
3554. Executes one bounded operation.
3565. Detaches in finalization.
3576. Maps protocol failures to typed errors.
358
359If DevTools or another debugger owns the target, return a typed automation error rather than disrupting it.
360
361Place pure logic in separate modules:
362
363- DOM summary extraction
364- selector generation
365- result clamping
366- key mapping
367- CDP response parsing
368
369## Desktop IPC
370
371Extend `DesktopPreviewBridge` with:
372
373```ts
374automation: {
375 status(...): Promise<...>;
376 snapshot(...): Promise<...>;
377 click(...): Promise<...>;
378 type(...): Promise<...>;
379 press(...): Promise<...>;
380 scroll(...): Promise<...>;
381 evaluate(...): Promise<...>;
382 waitFor(...): Promise<...>;
383}
384```
385
386Add schema-validated IPC channels and handlers.
387
388## Web Client
389
390Add a preview ownership hook mounted with `PreviewView`.
391
392Report changes to:
393
394- active environment/thread
395- tab id
396- panel visibility
397- window focus
398- Electron automation availability
399
400Handle broker requests:
401
402- `preview_open` opens the right panel for the active scoped thread.
403- Create a preview session and tab if needed.
404- Wait for webview registration.
405- Other operations invoke desktop automation IPC.
406- Always send a correlated success or failure response.
407
408Clear ownership on unmount, thread change, panel close, or desktop disconnect.
409
410## Provider Integration
411
412Add `McpSessionRegistry` integration to provider lifecycle.
413
414For each provider session:
415
4161. Issue a scoped MCP bearer token.
4172. Add the shared HTTP MCP configuration to session startup.
4183. Start or resume the agent session.
4194. Revoke the token during provider-session finalization.
420
421ACP providers use their existing HTTP MCP configuration fields.
422
423Codex uses its supported MCP/config override mechanism to register the same shared HTTP endpoint.
424
425Assume every supported provider can use HTTP MCP. Do not implement stdio fallback.
426
427## Remote Environment Behavior
428
429For a Mac mini environment viewed from a MacBook:
430
4311. Mac mini runs the T3 environment server and shared MCP endpoint.
4322. Agent session connects to that endpoint locally/remotely using its scoped token.
4333. Preview tool calls enter the Mac mini preview broker.
4344. Broker routes them over the existing T3 connection to the focused MacBook desktop.
4355. MacBook controls its visible Electron webview.
436
437URLs must already be reachable from the MacBook, such as:
438
439- `http://mac-mini.local:5173`
440- `http://192.168.1.42:5173`
441
442Warn when a remote environment opens `localhost`, `127.0.0.1`, or `::1`, because loopback resolves on the preview client.
443
444Preserve URL-resolution metadata:
445
446```ts
447{
448 requestedUrl: string;
449 resolvedUrl: string;
450 resolutionKind: "direct";
451 environmentId: EnvironmentId;
452}
453```
454
455This leaves room for future SSH, relay, or Tailscale resolution.
456
457## Tests
458
459### MCP Server
460
461- one MCP server layer starts per environment server
462- multiple authenticated MCP clients share the same server instance
463- each client receives its own invocation scope
464- toolkit registration is independent of transport
465- a mock future toolkit can register without changing server runtime
466- malformed parameters are rejected by Effect schemas
467- tool annotations appear in `tools/list`
468
469### Authentication
470
471- valid token resolves correct thread and provider session
472- concurrent tokens remain isolated
473- revoked and expired tokens fail
474- token cannot call unauthorized capability family
475- thread identity cannot be overridden in arguments
476- server restart invalidates tokens
477
478### Preview Broker
479
480- focused owner receives operation
481- most recently focused client wins
482- wrong-thread client is never selected
483- no owner returns typed error
484- disconnect fails pending calls
485- stale responses are ignored
486
487### Desktop and Web
488
489- agent can show and open preview
490- webview registration is awaited
491- CDP click, typing, key press, scroll, evaluation, and wait work
492- snapshot bounds screenshot, text, and interactive elements
493- ownership updates on focus and visibility changes
494- background threads are not automatically activated
495
496### End-to-End Integration
497
498Run two mocked agent sessions against one HTTP MCP server:
499
5001. Bind each bearer token to a different thread.
5012. Call `preview_status` concurrently.
5023. Verify each request routes to its own focused preview owner.
5034. Verify no MCP child process is spawned.
5045. Revoke one provider session and confirm only its MCP access fails.
505
506## Validation
507
508- `vp check`
509- `vp run typecheck`
510- `vp test`
511
512## Assumptions
513
514- HTTP MCP is supported by every target provider.
515- One MCP server is embedded in each T3 environment server.
516- MCP connection authentication supplies invocation identity.
517- Agents never know or pass T3 thread IDs.
518- Preview automation is the first of multiple future MCP toolkits.
519- Only Electron desktop preview clients support browser automation in v1.
520- No headless browser, stdio fallback, or automatic tunnel management is included.
.plans/visible-preview-browser-automation-via-cdp-mcp.md created+670
......@@ -0,0 +1,670 @@
1# Visible Preview Browser Automation via CDP + MCP
2
3## Summary
4
5Implement agent control of the user-visible T3 preview browser only. Do not add headless browser support. Do not add SSH/relay/private forwarding in v1; preview URLs must already be reachable from the desktop client, such as a Mac mini private IP URL opened on a MacBook.
6
7Architecture:
8
9`agent` -> `stdio MCP server in environment` -> `private T3 server bridge` -> `focused desktop client/window` -> `Electron preview webview via CDP`
10
11The stdio MCP server is the agent-facing integration because all target agents can speak MCP. The MCP server is intentionally thin: it does not automate Chromium directly. It calls the T3 environment server, which routes commands to the focused desktop client that owns the visible preview.
12
13## Explicit Non-Goals
14
15- No headless browser runner.
16- No Playwright-managed browser.
17- No arbitrary SSH dev-port forwarding in v1.
18- No Cloudflare/Tailscale/relay URL rewriting in v1.
19- No automation of browser/web clients that do not have Electron preview support.
20- No per-action user approval prompts in v1.
21
22## Key Decisions
23
24- **Transport to agents:** stdio MCP.
25- **Browser being controlled:** the actual integrated Electron preview webview.
26- **Remote URL handling:** manual reachable URLs only. Agents may open `http://mac-mini.local:5173` or `http://192.168.x.y:5173`; T3 will not tunnel `127.0.0.1:5173` from remote to local yet.
27- **Client routing:** route tool requests to the most recently focused desktop window/client for the agent’s thread. Return a typed error if no focused desktop preview owner is available.
28- **Scope:** full control of preview browser, including opening/showing the preview panel.
29- **Primary automation engine:** Chrome DevTools Protocol through Electron `webContents.debugger`, with `webContents.capturePage()` where it is simpler and more reliable.
30
31## New Contracts
32
33Add `packages/contracts/src/previewAutomation.ts`.
34
35### Branded IDs
36
37- `PreviewAutomationRequestId`
38- `PreviewAutomationClientId`
39- `PreviewAutomationOwnerId`
40
41### Tool Input/Output Schemas
42
43Add Effect schemas for these operations:
44
45- `PreviewAutomationOpenInput`
46 - `url?: string`
47 - `show?: boolean` default `true`
48 - `reuseExistingTab?: boolean` default `true`
49- `PreviewAutomationNavigateInput`
50 - `url: string`
51 - `waitUntil?: "load" | "domcontentloaded" | "network-idle" | "none"` default `"load"`
52 - `timeoutMs?: number` default `15000`
53- `PreviewAutomationSnapshotInput`
54 - `includeScreenshot?: boolean` default `true`
55 - `includeDomSummary?: boolean` default `true`
56 - `includeAccessibilityTree?: boolean` default `true`
57 - `screenshotMaxWidth?: number` default `1280`
58- `PreviewAutomationClickInput`
59 - one of:
60 - `{ selector: string }`
61 - `{ x: number; y: number }`
62 - `button?: "left" | "middle" | "right"` default `"left"`
63 - `clickCount?: number` default `1`
64- `PreviewAutomationTypeInput`
65 - `text: string`
66 - `selector?: string`
67 - `clearFirst?: boolean` default `false`
68- `PreviewAutomationPressInput`
69 - `key: string`
70 - `modifiers?: readonly ("alt" | "control" | "meta" | "shift")[]`
71- `PreviewAutomationScrollInput`
72 - `deltaX?: number`
73 - `deltaY?: number`
74 - optional target `{ selector: string }`
75- `PreviewAutomationEvaluateInput`
76 - `expression: string`
77 - `awaitPromise?: boolean` default `true`
78 - `returnByValue?: boolean` default `true`
79- `PreviewAutomationWaitForInput`
80 - one of:
81 - `{ selector: string }`
82 - `{ text: string }`
83 - `{ urlIncludes: string }`
84 - `timeoutMs?: number` default `10000`
85- `PreviewAutomationStatusResult`
86 - `available: boolean`
87 - `visible: boolean`
88 - `threadId`
89 - `tabId: string | null`
90 - `url: string | null`
91 - `title: string | null`
92 - `loading: boolean`
93 - `ownerClientId: string | null`
94
95### Result Shape
96
97Every mutating or stateful operation returns:
98
99```ts
100{
101 ok: boolean;
102 status: PreviewAutomationStatusResult;
103 message?: string;
104}
105```
106
107`snapshot` additionally returns:
108
109```ts
110{
111 status: PreviewAutomationStatusResult;
112 screenshot?: {
113 mimeType: "image/png";
114 dataBase64: string;
115 width: number;
116 height: number;
117 };
118 domSummary?: {
119 url: string;
120 title: string;
121 activeElement: string | null;
122 text: string;
123 interactiveElements: readonly {
124 index: number;
125 tag: string;
126 role: string | null;
127 name: string;
128 text: string;
129 selector: string | null;
130 rect: { x: number; y: number; width: number; height: number } | null;
131 }[];
132 };
133 accessibilityTree?: unknown;
134}
135```
136
137### Error Types
138
139Add tagged errors:
140
141- `PreviewAutomationUnavailableError`
142- `PreviewAutomationNoFocusedOwnerError`
143- `PreviewAutomationUnsupportedClientError`
144- `PreviewAutomationTabNotFoundError`
145- `PreviewAutomationTimeoutError`
146- `PreviewAutomationExecutionError`
147- `PreviewAutomationInvalidSelectorError`
148
149## Server-Side Broker
150
151Add `apps/server/src/previewAutomation/Services/PreviewAutomationBroker.ts`.
152
153Responsibilities:
154
155- Track connected desktop automation clients.
156- Track focus ownership by `(environmentId, threadId)`.
157- Accept tool calls from MCP/stdin proxy.
158- Route each call to the focused owner for the thread.
159- Enforce timeouts and cleanup pending requests on disconnect.
160- Return typed failures when no client/window is available.
161
162### Owner State
163
164Store:
165
166```ts
167{
168 clientId: PreviewAutomationClientId;
169 environmentId: EnvironmentId;
170 threadId: ThreadId;
171 tabId: PreviewTabId | null;
172 focusedAt: string;
173 visible: boolean;
174 supportsAutomation: boolean;
175}
176```
177
178Ownership updates come from the web/desktop client when:
179
180- route/thread changes,
181- preview panel opens/closes,
182- window focus changes,
183- tab id changes,
184- desktop bridge availability changes.
185
186## WS Bridge: Server to Desktop Client
187
188The current preview RPCs are client-to-server plus server event streams. Add a request/response bridge using stream-style RPCs to avoid introducing bidirectional RPC infrastructure.
189
190### New WS Methods
191
192Add to `packages/contracts/src/rpc.ts`:
193
194- `previewAutomation.connect`
195 - client calls this as a long-lived stream
196 - input:
197 - `clientId`
198 - `capabilities`
199 - stream output:
200 - `PreviewAutomationClientRequest`
201- `previewAutomation.respond`
202 - client sends response for a request id
203- `previewAutomation.reportOwner`
204 - client reports focus/visibility/thread ownership
205- `previewAutomation.clearOwner`
206 - client clears stale ownership on unmount/disconnect
207
208### Request Shape
209
210```ts
211{
212 requestId: string;
213 threadId: string;
214 tabId?: string;
215 operation:
216 | "open"
217 | "navigate"
218 | "snapshot"
219 | "click"
220 | "type"
221 | "press"
222 | "scroll"
223 | "evaluate"
224 | "waitFor"
225 | "status";
226 input: unknown;
227 timeoutMs: number;
228}
229```
230
231### Response Shape
232
233```ts
234{
235 requestId: string;
236 ok: boolean;
237 result?: unknown;
238 error?: {
239 _tag: string;
240 message: string;
241 detail?: unknown;
242 };
243}
244```
245
246## Desktop Preview Automation
247
248Extend `apps/desktop/src/preview-view-manager.ts`.
249
250### Add Methods
251
252- `getAutomationStatus(tabId)`
253- `captureSnapshot(tabId, options)`
254- `click(tabId, input)`
255- `type(tabId, input)`
256- `press(tabId, input)`
257- `scroll(tabId, input)`
258- `evaluate(tabId, input)`
259- `waitFor(tabId, input)`
260
261### CDP Session Handling
262
263Add a small helper inside desktop preview code:
264
265- Attach `webContents.debugger` lazily per operation.
266- Do not keep debugger attached forever unless needed.
267- If already attached by DevTools or another debugger, return `PreviewAutomationExecutionError`.
268- Use CDP domains:
269 - `Runtime.evaluate`
270 - `DOM.getDocument`
271 - `DOM.querySelector`
272 - `DOM.getBoxModel`
273 - `Accessibility.getFullAXTree`
274 - `Input.dispatchMouseEvent`
275 - `Input.dispatchKeyEvent`
276 - optionally `Page.captureScreenshot` if `webContents.capturePage()` is insufficient
277
278For screenshots, prefer `webContents.capturePage()` first because it is already used safely for annotations.
279
280### DOM Summary Script
281
282Use `Runtime.evaluate` with a bounded page script that returns:
283
284- `document.URL`
285- `document.title`
286- active element summary
287- visible text truncated to a fixed limit, e.g. 20k chars
288- up to 200 interactive elements:
289 - buttons
290 - links
291 - inputs
292 - selects
293 - textareas
294 - elements with roles
295 - elements with click handlers where detectable
296- stable-ish CSS selectors generated in page context
297- bounding rects
298
299Do not return full HTML by default.
300
301### Input Behavior
302
303- `click(selector)`:
304 - resolve selector in page
305 - scroll into view
306 - compute center of bounding box
307 - dispatch mouse move/down/up through CDP
308- `click(x, y)`:
309 - dispatch at viewport coordinates
310- `type(selector, text)`:
311 - focus selector if provided
312 - optionally clear existing value with platform shortcut
313 - dispatch text via CDP keyboard events or `Input.insertText`
314- `press(key)`:
315 - map common key names: Enter, Escape, Tab, Backspace, Arrow keys
316 - support modifiers
317- `scroll`:
318 - use CDP mouse wheel or page `scrollBy` fallback
319
320## Web Client Changes
321
322Update `apps/web/src/components/preview`.
323
324### Ownership Reporting
325
326Add a hook, likely `usePreviewAutomationOwner`, mounted near `PreviewView`.
327
328It reports owner state when:
329
330- the current route thread ref changes,
331- preview panel visibility changes,
332- browser window focus/blur changes,
333- tab id changes,
334- desktop preview bridge exists.
335
336Policy:
337
338- Only Electron desktop clients with `window.desktopBridge.preview` can report `supportsAutomation: true`.
339- A visible preview panel gets ownership.
340- The most recently focused window wins.
341- On unmount or preview close, clear ownership.
342
343### Handling Server Requests
344
345Add a client-side subscriber using the new `previewAutomation.connect` stream.
346
347When a request arrives:
348
349- if operation is `open`, ensure preview panel is visible for the thread, call `api.preview.open` if needed, mount/create desktop tab, then navigate if URL is provided.
350- for browser operations, call new `desktopBridge.preview.automation.*` methods.
351- send `previewAutomation.respond`.
352
353Opening behavior:
354
355- If the right panel is closed, open it to preview.
356- If the thread is not currently active in the UI, do not navigate the whole app in v1. Return `PreviewAutomationNoFocusedOwnerError`.
357- If preview is supported but no tab exists and the agent calls `open`, create one.
358- If no tab exists and the agent calls `snapshot/click/type/...`, return a clear “preview not open” error suggesting `preview_open`.
359
360## Desktop IPC / Preload
361
362Extend `packages/contracts/src/ipc.ts` `DesktopPreviewBridge`.
363
364Add:
365
366```ts
367automation: {
368 status(tabId: string): Promise<PreviewAutomationStatusResult>;
369 snapshot(tabId: string, input: PreviewAutomationSnapshotInput): Promise<PreviewAutomationSnapshotResult>;
370 click(tabId: string, input: PreviewAutomationClickInput): Promise<PreviewAutomationActionResult>;
371 type(tabId: string, input: PreviewAutomationTypeInput): Promise<PreviewAutomationActionResult>;
372 press(tabId: string, input: PreviewAutomationPressInput): Promise<PreviewAutomationActionResult>;
373 scroll(tabId: string, input: PreviewAutomationScrollInput): Promise<PreviewAutomationActionResult>;
374 evaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<PreviewAutomationEvaluateResult>;
375 waitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<PreviewAutomationActionResult>;
376}
377```
378
379Add IPC channels in `apps/desktop/src/ipc/channels.ts` and handlers in `apps/desktop/src/ipc/methods/preview.ts`.
380
381## Stdio MCP Server
382
383Add package: `packages/preview-mcp`.
384
385Purpose:
386
387- Implements the MCP stdio protocol.
388- Exposes T3 preview tools.
389- Calls a private loopback endpoint or local JSON-RPC bridge on the environment server.
390- Does not know Electron/CDP details.
391
392### Binary
393
394Expose bin:
395
396```json
397{
398 "bin": {
399 "t3-preview-mcp": "./dist/index.js"
400 }
401}
402```
403
404During local dev, provider config can call the source runner via workspace package script; production package uses built JS.
405
406### MCP Environment Variables
407
408Provider sessions launch the MCP server with:
409
410- `T3_PREVIEW_MCP_SERVER_URL`
411 - environment server loopback URL
412- `T3_PREVIEW_MCP_TOKEN`
413 - short-lived token scoped to the provider session/thread
414- `T3_PREVIEW_ENVIRONMENT_ID`
415- `T3_PREVIEW_THREAD_ID`
416
417The token must be generated by the environment server and expire when the provider session ends.
418
419### MCP Tools
420
421Expose these tools:
422
423- `preview_status`
424- `preview_open`
425- `preview_navigate`
426- `preview_snapshot`
427- `preview_click`
428- `preview_type`
429- `preview_press`
430- `preview_scroll`
431- `preview_evaluate`
432- `preview_wait_for`
433
434Tool descriptions must explicitly say they operate the visible T3 preview browser for the current thread.
435
436### MCP Output
437
438- Text results include concise status and URL/title.
439- `preview_snapshot` returns:
440 - text summary
441 - MCP image content for screenshot when available
442- Errors are MCP tool errors with the tagged T3 error message included.
443
444## Private MCP Bridge Endpoint
445
446Add an internal server route under `apps/server`, not public app UI:
447
448- `POST /internal/preview-automation/tool`
449- Auth: bearer `T3_PREVIEW_MCP_TOKEN`
450- Body:
451 - `{ tool: string, input: unknown }`
452- Response:
453 - `{ ok: true, result } | { ok: false, error }`
454
455This endpoint is only for the stdio MCP proxy. It calls `PreviewAutomationBroker`.
456
457Bind it to the same host/port as the environment server, but require the short-lived token. The endpoint must reject requests without a token or with an expired/stale thread/session.
458
459## Provider Integration
460
461### Codex
462
463When starting a Codex provider session, add the T3 preview MCP server to Codex configuration if the app-server config path supports per-thread MCP injection.
464
465Implementation path:
466
4671. Add `PreviewMcpSessionService` in `apps/server`.
4682. On provider session start:
469 - create scoped MCP token for `(environmentId, threadId, providerSessionId)`.
470 - build MCP server config:
471 - name: `t3-preview`
472 - command: `t3-preview-mcp`
473 - env vars listed above
4743. Thread/session start passes the MCP server config through the provider’s supported config field.
4754. If Codex app-server cannot accept injected MCP servers through typed params, use its `config` override field with Codex-compatible MCP config.
476
477### ACP Providers
478
479ACP session creation already passes `mcpServers: []`. Replace that with the same `t3-preview` MCP server config for providers that support MCP.
480
481### Provider Fallback
482
483If a provider does not support MCP injection yet, do not add provider-specific native tools in v1. The shared MCP server remains available for later provider wiring.
484
485## Remote Environment Behavior
486
487### Mac mini dev server viewed from MacBook
488
489Expected v1 workflow:
490
4911. Agent runs dev server on Mac mini.
4922. Agent or user opens a reachable URL, e.g.:
493 - `http://mac-mini.local:5173`
494 - `http://192.168.1.42:5173`
4953. T3 desktop on MacBook opens that URL in the local Electron preview.
4964. Agent uses MCP tools.
4975. T3 routes tool calls from Mac mini environment server to the focused MacBook preview client.
498
499### `localhost` Caveat
500
501In v1, if an agent opens `http://localhost:5173` from a remote environment, the MacBook preview will interpret that as MacBook localhost. The tool result should include a warning when:
502
503- environment is not the primary/local environment, and
504- URL hostname is `localhost`, `127.0.0.1`, or `::1`.
505
506Warning text:
507
508`This URL is loopback on the preview client, not necessarily the remote environment. Use a client-reachable host/IP for remote dev servers.`
509
510### Future-Proofing
511
512Do not bake manual URL assumptions into the automation layer. Represent opened URLs as:
513
514```ts
515{
516 displayUrl: string;
517 requestedUrl: string;
518 resolutionKind: "direct";
519 environmentId: string;
520}
521```
522
523Later `resolutionKind` can add:
524
525- `ssh-forward`
526- `relay`
527- `tailscale`
528- `cloudflare-tunnel`
529
530## Security and Safety
531
532- MCP tokens are scoped to one provider session/thread.
533- MCP tokens expire on provider session stop and server restart.
534- Browser automation only routes to focused desktop owner for that same thread.
535- Do not allow MCP input to specify arbitrary `environmentId` or `threadId`; infer both from token/session.
536- `preview_evaluate` is powerful. Keep it enabled in v1 because the user requested full control, but:
537 - limit result serialization size, e.g. 64 KB
538 - timeout evaluation
539 - return by value by default
540 - document that it executes in the preview page context
541- Screenshot output should be bounded:
542 - max dimensions
543 - max base64 size
544 - return error if too large after scaling attempts
545- Clear pending broker requests when desktop disconnects.
546- CDP operations must timeout and detach debugger in `finally`.
547
548## Failure Modes
549
550Return typed errors for:
551
552- no desktop client connected
553- desktop client connected but preview unsupported
554- no focused owner for thread
555- preview panel not open and operation is not `preview_open`
556- webview not initialized yet
557- navigation timeout
558- selector not found
559- CDP debugger unavailable
560- page execution error
561- screenshot too large
562- stale request id or response after timeout
563
564## Tests
565
566### Contracts
567
568Add tests for:
569
570- schema decoding for every preview automation input/result
571- error schema decoding
572- invalid selector/click union inputs rejected
573- snapshot options defaulting
574
575### Desktop Unit Tests
576
577Add tests around `PreviewViewManager` helpers:
578
579- owner-independent status when tab exists/does not exist
580- selector summary script clamps output
581- selector generation handles ids/classes/nth-child fallback
582- error mapping for missing webContents
583- screenshot result shape
584
585Where Electron/CDP is hard to unit test, isolate pure helpers and cover IPC handler validation.
586
587### Server Broker Tests
588
589Add tests for:
590
591- registering clients
592- ownership updates
593- focused owner wins
594- request routed to focused owner
595- request timeout
596- client disconnect fails pending requests
597- response with unknown request id ignored/rejected
598- token scoped to thread/session
599- remote loopback URL warning generated
600
601### Web Client Tests
602
603Add tests for:
604
605- ownership report sent only in Electron preview-supported runtime
606- opening preview panel on `preview_open`
607- no route switch for background thread
608- clear ownership on unmount
609- response sent for successful and failed desktop bridge calls
610
611### MCP Tests
612
613Add tests for:
614
615- tool list includes all expected preview tools
616- each tool maps to internal bridge request
617- token missing/invalid returns MCP error
618- snapshot maps screenshot to MCP image content
619- tool errors preserve tagged T3 error message
620
621### Integration Tests
622
623Add a focused integration test using mocked desktop client:
624
6251. Start server broker.
6262. Register fake desktop automation client.
6273. Mark it focused for thread.
6284. Invoke MCP `preview_open`.
6295. Assert fake client received open request.
6306. Respond success.
6317. Assert MCP response is successful.
632
633Add a second integration test:
634
6351. No focused owner.
6362. Invoke `preview_snapshot`.
6373. Assert `PreviewAutomationNoFocusedOwnerError`.
638
639## Validation Commands
640
641Before completion:
642
643- `vp check`
644- `vp run typecheck`
645- `vp test`
646
647No `vp run lint:mobile` required unless mobile code is changed.
648
649## Implementation Order
650
6511. Add contracts for preview automation schemas and WS methods.
6522. Add `PreviewAutomationBroker` server service.
6533. Add WS stream bridge for desktop clients.
6544. Add desktop IPC and `PreviewViewManager` CDP automation methods.
6555. Add web ownership reporting and request handling.
6566. Add private `/internal/preview-automation/tool` endpoint with scoped token auth.
6577. Add `packages/preview-mcp` stdio MCP server.
6588. Wire provider sessions to launch/register `t3-preview` MCP server where provider protocols support MCP config.
6599. Add remote loopback URL warning.
66010. Add tests.
66111. Run validation commands.
662
663## Assumptions
664
665- v1 only supports Electron desktop preview clients.
666- The active/focused thread preview is the right target for agent control.
667- Manual client-reachable URLs are acceptable for remote dev servers.
668- Full browser control includes `evaluate`.
669- Stdio MCP is the agent-facing transport.
670- A private server bridge behind the stdio MCP server is acceptable and necessary because the browser lives on the desktop client, not beside the remote agent.
apps/desktop/scripts/dev-electron.mjs+7-2
......@@ -2,7 +2,11 @@ import { spawn, spawnSync } from "node:child_process";
22import { watch } from "node:fs";
33import { join } from "node:path";
44
5import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
5import {
6 desktopDir,
7 resolveDevProtocolClient,
8 resolveElectronLaunchCommand,
9} from "./electron-launcher.mjs";
610import { waitForResources } from "./wait-for-resources.mjs";
711
812const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
......@@ -79,7 +83,8 @@ function startApp() {
7983 const launchArgs = devProtocolClient
8084 ? electronArgs
8185 : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
82 const app = spawn(resolveElectronPath(), launchArgs, {
86 const electronCommand = resolveElectronLaunchCommand(launchArgs);
87 const app = spawn(electronCommand.electronPath, electronCommand.args, {
8388 cwd: desktopDir,
8489 env: childEnv,
8590 stdio: "inherit",
apps/desktop/scripts/electron-launcher.mjs+33
......@@ -307,6 +307,31 @@ function buildMacLauncher(electronBinaryPath) {
307307 return targetBinaryPath;
308308}
309309
310function isLinuxSetuidSandboxConfigured(electronBinaryPath) {
311 if (process.platform !== "linux") {
312 return true;
313 }
314
315 const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox");
316 try {
317 const sandboxStat = statSync(sandboxPath);
318 return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755;
319 } catch {
320 return false;
321 }
322}
323
324function resolveLinuxSandboxArgs(electronBinaryPath) {
325 if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) {
326 return [];
327 }
328
329 console.warn(
330 "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.",
331 );
332 return ["--no-sandbox"];
333}
334
310335export function resolveElectronPath() {
311336 ensureElectronRuntime();
312337
......@@ -320,6 +345,14 @@ export function resolveElectronPath() {
320345 return buildMacLauncher(electronBinaryPath);
321346}
322347
348export function resolveElectronLaunchCommand(args = []) {
349 const electronPath = resolveElectronPath();
350 return {
351 electronPath,
352 args: [...resolveLinuxSandboxArgs(electronPath), ...args],
353 };
354}
355
323356export function resolveDevProtocolClient() {
324357 if (process.platform !== "darwin" || !isDevelopment) {
325358 return null;
apps/desktop/scripts/smoke-test.mjs+3-2
......@@ -1,15 +1,16 @@
11import { spawn } from "node:child_process";
22import { dirname, resolve } from "node:path";
33import { fileURLToPath } from "node:url";
4import { resolveElectronLaunchCommand } from "./electron-launcher.mjs";
45
56const __dirname = dirname(fileURLToPath(import.meta.url));
67const desktopDir = resolve(__dirname, "..");
7const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
88const mainJs = resolve(desktopDir, "dist-electron/main.cjs");
99
1010console.log("\nLaunching Electron smoke test...");
1111
12const child = spawn(electronBin, [mainJs], {
12const electronCommand = resolveElectronLaunchCommand([mainJs]);
13const child = spawn(electronCommand.electronPath, electronCommand.args, {
1314 stdio: ["pipe", "pipe", "pipe"],
1415 env: {
1516 ...process.env,
apps/desktop/scripts/start-electron.mjs+3-2
......@@ -1,11 +1,12 @@
11import { spawn } from "node:child_process";
22
3import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
3import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";
44
55const childEnv = { ...process.env };
66delete childEnv.ELECTRON_RUN_AS_NODE;
77
8const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], {
8const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]);
9const child = spawn(electronCommand.electronPath, electronCommand.args, {
910 stdio: "inherit",
1011 cwd: desktopDir,
1112 env: childEnv,
apps/desktop/src/ipc/channels.ts+8
......@@ -56,4 +56,12 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
5656export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
5757export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
5858export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
59export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
60export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
61export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";
62export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type";
63export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press";
64export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";
65export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";
66export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for";
5967export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
apps/desktop/src/ipc/methods/preview.ts+49
......@@ -28,6 +28,13 @@ const tabIdFrom = (raw: unknown): string => {
2828 return tabId;
2929};
3030
31const inputFrom = (raw: unknown): unknown => {
32 if (typeof raw !== "object" || raw === null || !("input" in raw)) {
33 throw new Error("preview automation input is required");
34 }
35 return raw.input;
36};
37
3138class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{
3239 readonly cause: unknown;
3340}> {}
......@@ -102,4 +109,46 @@ export const previewMethods = [
102109 method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) =>
103110 previewViewManager.cancelPickElement(tabIdFrom(raw)),
104111 ),
112 method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) =>
113 previewViewManager.automationStatus(tabIdFrom(raw)),
114 ),
115 method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) =>
116 previewViewManager.automationSnapshot(tabIdFrom(raw)),
117 ),
118 method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) =>
119 previewViewManager.automationClick(
120 tabIdFrom(raw),
121 inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1],
122 ),
123 ),
124 method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) =>
125 previewViewManager.automationType(
126 tabIdFrom(raw),
127 inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1],
128 ),
129 ),
130 method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) =>
131 previewViewManager.automationPress(
132 tabIdFrom(raw),
133 inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1],
134 ),
135 ),
136 method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) =>
137 previewViewManager.automationScroll(
138 tabIdFrom(raw),
139 inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1],
140 ),
141 ),
142 method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) =>
143 previewViewManager.automationEvaluate(
144 tabIdFrom(raw),
145 inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1],
146 ),
147 ),
148 method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) =>
149 previewViewManager.automationWaitFor(
150 tabIdFrom(raw),
151 inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1],
152 ),
153 ),
105154] as const;
apps/desktop/src/preload.ts+18
......@@ -163,6 +163,24 @@ contextBridge.exposeInMainWorld("desktopBridge", {
163163 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
164164 cancelPickElement: (tabId) =>
165165 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
166 automation: {
167 status: (tabId) =>
168 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }),
169 snapshot: (tabId) =>
170 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }),
171 click: (tabId, input) =>
172 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }),
173 type: (tabId, input) =>
174 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }),
175 press: (tabId, input) =>
176 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }),
177 scroll: (tabId, input) =>
178 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }),
179 evaluate: (tabId, input) =>
180 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }),
181 waitFor: (tabId, input) =>
182 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }),
183 },
166184 onStateChange: (listener) => {
167185 const wrappedListener = (
168186 _event: Electron.IpcRendererEvent,
apps/desktop/src/preview-view-manager.test.ts created+44
......@@ -0,0 +1,44 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const fromId = vi.fn(() => null);
4
5vi.mock("electron", () => ({
6 session: {
7 fromPartition: vi.fn(),
8 },
9 webContents: {
10 fromId,
11 },
12}));
13
14describe("PreviewViewManager automation status", () => {
15 beforeEach(() => {
16 fromId.mockClear();
17 });
18
19 it("reports an unregistered webview as temporarily unavailable", async () => {
20 const { PreviewViewManager } = await import("./preview-view-manager.ts");
21 const manager = new PreviewViewManager();
22
23 expect(manager.automationStatus("tab_1")).toEqual({
24 available: false,
25 visible: true,
26 tabId: "tab_1",
27 url: null,
28 title: null,
29 loading: false,
30 });
31
32 manager.createTab("tab_1");
33
34 expect(manager.automationStatus("tab_1")).toEqual({
35 available: false,
36 visible: true,
37 tabId: "tab_1",
38 url: null,
39 title: null,
40 loading: false,
41 });
42 expect(fromId).not.toHaveBeenCalled();
43 });
44});
apps/desktop/src/preview-view-manager.ts+484-1
......@@ -6,9 +6,21 @@
66 * elements live in the renderer; we only attach listeners and forward state
77 * here). Single layer-scoped browser session partition.
88 */
9import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts";
9import type {
10 PreviewAnnotationPayload,
11 PreviewAnnotationRect,
12 PreviewAutomationClickInput,
13 PreviewAutomationEvaluateInput,
14 PreviewAutomationPressInput,
15 PreviewAutomationScrollInput,
16 PreviewAutomationSnapshot,
17 PreviewAutomationStatus,
18 PreviewAutomationTypeInput,
19 PreviewAutomationWaitForInput,
20} from "@t3tools/contracts";
1021import { normalizePreviewUrl } from "@t3tools/shared/preview";
1122import { type BrowserWindow, type Session, session, webContents } from "electron";
23import { setTimeout as sleep } from "node:timers/promises";
1224
1325import { isPreviewAnnotationPayload } from "./picked-element-payload.ts";
1426
......@@ -53,6 +65,36 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [
5365
5466const DEFAULT_ZOOM_FACTOR = 1.0;
5567const ZOOM_EPSILON = 0.001;
68const MAX_EVALUATION_BYTES = 64_000;
69const MAX_VISIBLE_TEXT_LENGTH = 20_000;
70const MAX_INTERACTIVE_ELEMENTS = 200;
71const MAX_SCREENSHOT_WIDTH = 1280;
72
73interface CdpEvaluationResult {
74 readonly result?: {
75 readonly value?: unknown;
76 readonly description?: string;
77 };
78 readonly exceptionDetails?: {
79 readonly text?: string;
80 readonly exception?: { readonly description?: string };
81 };
82}
83
84const automationError = (
85 tag:
86 | "PreviewAutomationExecutionError"
87 | "PreviewAutomationInvalidSelectorError"
88 | "PreviewAutomationResultTooLargeError"
89 | "PreviewAutomationTimeoutError",
90 message: string,
91 detail?: unknown,
92): Error & { detail?: unknown } => {
93 const error = new Error(message) as Error & { detail?: unknown };
94 error.name = tag;
95 if (detail !== undefined) error.detail = detail;
96 return error;
97};
5698
5799const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
58100 if (typeof value !== "object" || value === null) return null;
......@@ -455,6 +497,391 @@ export class PreviewViewManager {
455497 this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR);
456498 }
457499
500 automationStatus(tabId: string): PreviewAutomationStatus {
501 const tab = this.tabs.get(tabId);
502 if (!tab || tab.webContentsId == null) {
503 const navStatus = tab?.navStatus;
504 return {
505 available: false,
506 visible: true,
507 tabId,
508 url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url,
509 title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title,
510 loading: navStatus?.kind === "Loading",
511 };
512 }
513 const wc = webContents.fromId(tab.webContentsId);
514 if (!wc || wc.isDestroyed()) {
515 return {
516 available: false,
517 visible: true,
518 tabId,
519 url: null,
520 title: null,
521 loading: false,
522 };
523 }
524 return {
525 available: true,
526 visible: true,
527 tabId,
528 url: wc.getURL() || null,
529 title: wc.getTitle() || null,
530 loading: wc.isLoading(),
531 };
532 }
533
534 async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> {
535 const wc = this.requireWebContents(tabId);
536 return this.withDebugger(wc, async (send) => {
537 await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]);
538 const page = await this.evaluateWithDebugger<{
539 url: string;
540 title: string;
541 loading: boolean;
542 visibleText: string;
543 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
544 }>(
545 send,
546 `(() => {
547 const selectorFor = (element) => {
548 if (element.id) return "#" + CSS.escape(element.id);
549 for (const attribute of ["data-testid", "name"]) {
550 const value = element.getAttribute(attribute);
551 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
552 }
553 const parts = [];
554 let current = element;
555 while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) {
556 let part = current.tagName.toLowerCase();
557 const parent = current.parentElement;
558 if (parent) {
559 const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
560 if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")";
561 }
562 parts.unshift(part);
563 current = parent;
564 }
565 return parts.join(" > ");
566 };
567 const visible = (element) => {
568 const style = getComputedStyle(element);
569 const rect = element.getBoundingClientRect();
570 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
571 };
572 const elements = Array.from(document.querySelectorAll(
573 "a[href],button,input,textarea,select,[role],[tabindex]"
574 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
575 const rect = element.getBoundingClientRect();
576 return {
577 tag: element.tagName.toLowerCase(),
578 role: element.getAttribute("role"),
579 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
580 selector: selectorFor(element),
581 x: rect.x,
582 y: rect.y,
583 width: rect.width,
584 height: rect.height
585 };
586 });
587 return {
588 url: location.href,
589 title: document.title,
590 loading: document.readyState !== "complete",
591 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
592 interactiveElements: elements
593 };
594 })()`,
595 true,
596 );
597 const accessibility = await send("Accessibility.getFullAXTree");
598 let image = await wc.capturePage();
599 let size = image.getSize();
600 if (size.width > MAX_SCREENSHOT_WIDTH) {
601 image = image.resize({ width: MAX_SCREENSHOT_WIDTH });
602 size = image.getSize();
603 }
604 return {
605 ...page,
606 accessibilityTree: accessibility,
607 screenshot: {
608 mimeType: "image/png",
609 data: image.toPNG().toString("base64"),
610 width: size.width,
611 height: size.height,
612 },
613 };
614 });
615 }
616
617 async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> {
618 const wc = this.requireWebContents(tabId);
619 await this.withDebugger(wc, async (send) => {
620 await Promise.all([
621 send("Runtime.enable"),
622 send("Input.setIgnoreInputEvents", { ignore: false }),
623 ]);
624 let x: number;
625 let y: number;
626 if ("selector" in input) {
627 const point = await this.evaluateWithDebugger<
628 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
629 >(
630 send,
631 `(() => {
632 try {
633 const element = document.querySelector(${JSON.stringify(input.selector)});
634 if (!element) return { notFound: true };
635 element.scrollIntoView({ block: "center", inline: "center" });
636 const rect = element.getBoundingClientRect();
637 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
638 } catch (error) {
639 return { invalidSelector: true, message: String(error) };
640 }
641 })()`,
642 true,
643 );
644 if ("invalidSelector" in point) {
645 throw automationError("PreviewAutomationInvalidSelectorError", point.message, {
646 selector: input.selector,
647 });
648 }
649 if ("notFound" in point) {
650 throw automationError(
651 "PreviewAutomationExecutionError",
652 `No element matches selector ${input.selector}.`,
653 );
654 }
655 x = point.x;
656 y = point.y;
657 } else {
658 x = input.x;
659 y = input.y;
660 }
661 const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>(
662 send,
663 "({ width: window.innerWidth, height: window.innerHeight })",
664 true,
665 );
666 if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) {
667 throw automationError(
668 "PreviewAutomationExecutionError",
669 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,
670 );
671 }
672 await send("Input.dispatchMouseEvent", {
673 type: "mousePressed",
674 x,
675 y,
676 button: "left",
677 clickCount: 1,
678 });
679 await send("Input.dispatchMouseEvent", {
680 type: "mouseReleased",
681 x,
682 y,
683 button: "left",
684 clickCount: 1,
685 });
686 });
687 }
688
689 async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> {
690 const wc = this.requireWebContents(tabId);
691 await this.withDebugger(wc, async (send) => {
692 await send("Runtime.enable");
693 const focusResult = await this.evaluateWithDebugger<
694 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
695 >(
696 send,
697 `(() => {
698 try {
699 const element = ${
700 input.selector
701 ? `document.querySelector(${JSON.stringify(input.selector)})`
702 : "document.activeElement"
703 };
704 if (!element) return { notFound: true };
705 element.focus();
706 if (${input.clear ?? false}) {
707 if ("value" in element) element.value = "";
708 else if (element.isContentEditable) element.textContent = "";
709 element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
710 }
711 return { ok: true };
712 } catch (error) {
713 return { invalidSelector: true, message: String(error) };
714 }
715 })()`,
716 true,
717 );
718 if ("invalidSelector" in focusResult) {
719 throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, {
720 selector: input.selector ?? "",
721 });
722 }
723 if ("notFound" in focusResult) {
724 throw automationError(
725 "PreviewAutomationExecutionError",
726 input.selector
727 ? `No element matches selector ${input.selector}.`
728 : "No element is focused in the preview.",
729 );
730 }
731 await send("Input.insertText", { text: input.text });
732 await this.evaluateWithDebugger(
733 send,
734 `(() => {
735 const element = document.activeElement;
736 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} }));
737 element?.dispatchEvent(new Event("change", { bubbles: true }));
738 })()`,
739 false,
740 );
741 });
742 }
743
744 async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> {
745 const wc = this.requireWebContents(tabId);
746 await this.withDebugger(wc, async (send) => {
747 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
748 switch (modifier) {
749 case "Alt":
750 return value | 1;
751 case "Control":
752 return value | 2;
753 case "Meta":
754 return value | 4;
755 case "Shift":
756 return value | 8;
757 }
758 }, 0);
759 const key = input.key;
760 const text = key.length === 1 ? key : undefined;
761 const params = {
762 key,
763 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
764 modifiers,
765 ...(text ? { text, unmodifiedText: text } : {}),
766 };
767 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
768 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
769 });
770 }
771
772 async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> {
773 const wc = this.requireWebContents(tabId);
774 await this.withDebugger(wc, async (send) => {
775 await send("Runtime.enable");
776 const result = await this.evaluateWithDebugger<
777 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
778 >(
779 send,
780 `(() => {
781 try {
782 const target = ${
783 input.selector
784 ? `document.querySelector(${JSON.stringify(input.selector)})`
785 : "window"
786 };
787 if (!target) return { notFound: true };
788 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
789 return { ok: true };
790 } catch (error) {
791 return { invalidSelector: true, message: String(error) };
792 }
793 })()`,
794 true,
795 );
796 if ("invalidSelector" in result) {
797 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
798 selector: input.selector ?? "",
799 });
800 }
801 if ("notFound" in result) {
802 throw automationError(
803 "PreviewAutomationExecutionError",
804 `No element matches selector ${input.selector}.`,
805 );
806 }
807 });
808 }
809
810 async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> {
811 const wc = this.requireWebContents(tabId);
812 return this.withDebugger(wc, async (send) => {
813 await send("Runtime.enable");
814 const value = await this.evaluateWithDebugger(
815 send,
816 input.expression,
817 input.returnByValue ?? true,
818 input.awaitPromise ?? true,
819 );
820 const serialized = JSON.stringify(value);
821 if (
822 serialized !== undefined &&
823 Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES
824 ) {
825 throw automationError(
826 "PreviewAutomationResultTooLargeError",
827 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
828 { maximumBytes: MAX_EVALUATION_BYTES },
829 );
830 }
831 return value;
832 });
833 }
834
835 async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> {
836 const wc = this.requireWebContents(tabId);
837 const timeoutMs = input.timeoutMs ?? 15_000;
838 await this.withDebugger(wc, async (send) => {
839 await send("Runtime.enable");
840 const deadline = Date.now() + timeoutMs;
841 while (Date.now() <= deadline) {
842 const result = await this.evaluateWithDebugger<
843 { matched: boolean } | { invalidSelector: true; message: string }
844 >(
845 send,
846 `(() => {
847 try {
848 const selectorMatched = ${
849 input.selector
850 ? `document.querySelector(${JSON.stringify(input.selector)}) !== null`
851 : "true"
852 };
853 const textMatched = ${
854 input.text
855 ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})`
856 : "true"
857 };
858 const urlMatched = ${
859 input.urlIncludes
860 ? `location.href.includes(${JSON.stringify(input.urlIncludes)})`
861 : "true"
862 };
863 return { matched: selectorMatched && textMatched && urlMatched };
864 } catch (error) {
865 return { invalidSelector: true, message: String(error) };
866 }
867 })()`,
868 true,
869 );
870 if ("invalidSelector" in result) {
871 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
872 selector: input.selector ?? "",
873 });
874 }
875 if (result.matched) return;
876 await sleep(100);
877 }
878 throw automationError(
879 "PreviewAutomationTimeoutError",
880 `Preview condition did not match within ${timeoutMs}ms.`,
881 );
882 });
883 }
884
458885 private applyZoom(tabId: string, transform: (current: number) => number): void {
459886 const tab = this.tabs.get(tabId);
460887 if (!tab) return;
......@@ -469,6 +896,62 @@ export class PreviewViewManager {
469896 this.update(tabId, { zoomFactor: next });
470897 }
471898
899 private async withDebugger<A>(
900 wc: Electron.WebContents,
901 use: (
902 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
903 ) => Promise<A>,
904 ): Promise<A> {
905 if (wc.debugger.isAttached()) {
906 throw automationError(
907 "PreviewAutomationExecutionError",
908 "Preview automation is unavailable while another debugger is attached.",
909 );
910 }
911 wc.debugger.attach("1.3");
912 try {
913 return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams));
914 } catch (cause) {
915 if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause;
916 throw automationError(
917 "PreviewAutomationExecutionError",
918 cause instanceof Error ? cause.message : String(cause),
919 cause,
920 );
921 } finally {
922 if (wc.debugger.isAttached()) {
923 try {
924 wc.debugger.detach();
925 } catch {
926 // The target can disappear while an operation is completing.
927 }
928 }
929 }
930 }
931
932 private async evaluateWithDebugger<A = unknown>(
933 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
934 expression: string,
935 returnByValue: boolean,
936 awaitPromise = true,
937 ): Promise<A> {
938 const response = (await send("Runtime.evaluate", {
939 expression,
940 awaitPromise,
941 returnByValue,
942 userGesture: true,
943 })) as CdpEvaluationResult;
944 if (response.exceptionDetails) {
945 throw automationError(
946 "PreviewAutomationExecutionError",
947 response.exceptionDetails.exception?.description ??
948 response.exceptionDetails.text ??
949 "JavaScript evaluation failed.",
950 );
951 }
952 return response.result?.value as A;
953 }
954
472955 onStateChange(listener: Listener): () => void {
473956 this.listeners.add(listener);
474957 return () => {
apps/server/src/mcp/Layers/McpHttpServer.test.ts created+133
......@@ -0,0 +1,133 @@
1import { expect, it } from "@effect/vitest";
2import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { McpSchema, McpServer } from "effect/unstable/ai";
7import { HttpServerResponse } from "effect/unstable/http";
8
9import { McpInvocationContext } from "../Services/McpInvocationContext.ts";
10import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts";
11import { previewAutomationBroker } from "./PreviewAutomationBroker.ts";
12
13const environmentId = EnvironmentId.make("environment-mcp-test");
14const threadId = ThreadId.make("thread-mcp-test");
15const tabId = PreviewTabId.make("tab-mcp-test");
16const invocation = {
17 environmentId,
18 threadId,
19 providerSessionId: "provider-session-mcp-test",
20 providerInstanceId: ProviderInstanceId.make("codex"),
21 capabilities: new Set(["preview"] as const),
22 issuedAt: 1,
23 expiresAt: Number.MAX_SAFE_INTEGER,
24};
25const client = McpSchema.McpServerClient.of({
26 clientId: 1,
27 initializePayload: {
28 protocolVersion: "2025-03-26",
29 capabilities: {},
30 clientInfo: { name: "mcp-test", version: "1.0.0" },
31 },
32 getClient: Effect.die("unused"),
33});
34const TestLayer = PreviewToolkitRegistrationLive.pipe(
35 Layer.provideMerge(McpServer.McpServer.layer),
36);
37
38it("normalizes empty successful notification responses to accepted", () => {
39 const notificationResponse = normalizeMcpHttpResponse(
40 HttpServerResponse.text("", { status: 200, contentType: "application/json" }),
41 );
42 expect(notificationResponse.status).toBe(202);
43
44 const resultResponse = normalizeMcpHttpResponse(
45 HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }),
46 );
47 expect(resultResponse.status).toBe(200);
48});
49
50it.effect("registers annotated tools and preserves authenticated request context", () =>
51 Effect.scoped(
52 Effect.gen(function* () {
53 const server = yield* McpServer.McpServer;
54 const requests = yield* previewAutomationBroker.connect("mcp-test-client");
55 yield* Stream.runForEach(requests, (request) =>
56 previewAutomationBroker.respond({
57 requestId: request.requestId,
58 ok: true,
59 result:
60 request.operation === "snapshot"
61 ? {
62 url: "http://example.test/",
63 title: "Example",
64 loading: false,
65 visibleText: "Example",
66 interactiveElements: [],
67 accessibilityTree: {},
68 screenshot: {
69 mimeType: "image/png",
70 data: Buffer.from("png").toString("base64"),
71 width: 10,
72 height: 5,
73 },
74 }
75 : {
76 available: true,
77 visible: true,
78 tabId,
79 url: "http://example.test/",
80 title: "Example",
81 loading: false,
82 },
83 }),
84 ).pipe(Effect.forkScoped);
85 yield* Effect.yieldNow;
86 yield* previewAutomationBroker.reportOwner({
87 clientId: "mcp-test-client",
88 environmentId,
89 threadId,
90 tabId,
91 visible: true,
92 supportsAutomation: true,
93 focusedAt: "2026-06-11T00:00:00.000Z",
94 });
95
96 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
97 expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true);
98 expect(statusTool?.tool.annotations?.idempotentHint).toBe(true);
99
100 const status = yield* server
101 .callTool({ name: "preview_status", arguments: {} })
102 .pipe(
103 Effect.provideService(McpInvocationContext, invocation),
104 Effect.provideService(McpSchema.McpServerClient, client),
105 );
106 expect(status.isError).toBe(false);
107 expect(status.structuredContent).toMatchObject({
108 available: true,
109 tabId,
110 });
111
112 const malformed = yield* server
113 .callTool({ name: "preview_click", arguments: { selector: "" } })
114 .pipe(
115 Effect.provideService(McpInvocationContext, invocation),
116 Effect.provideService(McpSchema.McpServerClient, client),
117 );
118 expect(malformed.isError).toBe(true);
119
120 const snapshot = yield* server
121 .callTool({ name: "preview_snapshot", arguments: {} })
122 .pipe(
123 Effect.provideService(McpInvocationContext, invocation),
124 Effect.provideService(McpSchema.McpServerClient, client),
125 );
126 expect(snapshot.isError).toBe(false);
127 expect(snapshot.content.some((content) => content.type === "image")).toBe(true);
128 expect(snapshot.structuredContent).toMatchObject({
129 screenshot: { mimeType: "image/png", width: 10, height: 5 },
130 });
131 }),
132 ).pipe(Effect.provide(TestLayer)),
133);
apps/server/src/mcp/Layers/McpHttpServer.ts created+162
......@@ -0,0 +1,162 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
9import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
10
11import packageJson from "../../../package.json" with { type: "json" };
12import { McpInvocationContext } from "../Services/McpInvocationContext.ts";
13import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts";
14import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts";
15import { PreviewToolkit } from "../toolkits/preview/tools.ts";
16
17const unauthorized = HttpServerResponse.jsonUnsafe(
18 {
19 error: "invalid_mcp_credential",
20 message: "A valid provider-scoped MCP bearer credential is required.",
21 },
22 {
23 status: 401,
24 headers: {
25 "cache-control": "no-store",
26 "www-authenticate": "Bearer",
27 },
28 },
29);
30
31export const normalizeMcpHttpResponse = (
32 response: HttpServerResponse.HttpServerResponse,
33): HttpServerResponse.HttpServerResponse => {
34 const bodyIsEmpty =
35 response.body._tag === "Empty" ||
36 (response.body._tag === "Uint8Array" && response.body.contentLength === 0) ||
37 (response.body._tag === "Raw" && response.body.contentLength === 0);
38 return response.status === 200 && bodyIsEmpty
39 ? HttpServerResponse.setStatus(response, 202)
40 : response;
41};
42
43const McpAuthMiddlewareLive = HttpRouter.middleware<{
44 provides: McpInvocationContext;
45}>()(
46 Effect.gen(function* () {
47 const registry = yield* McpSessionRegistry;
48 return (httpEffect) =>
49 Effect.gen(function* () {
50 const request = yield* HttpServerRequest.HttpServerRequest;
51 const authorization = request.headers.authorization;
52 const token =
53 authorization?.startsWith("Bearer ") === true
54 ? authorization.slice("Bearer ".length).trim()
55 : "";
56 const invocation = yield* registry.resolve(token);
57 if (!invocation) return unauthorized;
58 return yield* httpEffect.pipe(
59 Effect.provideService(McpInvocationContext, invocation),
60 Effect.map(normalizeMcpHttpResponse),
61 );
62 });
63 }),
64).layer;
65
66const McpTransportLive = McpServer.layerHttp({
67 name: "T3 Code",
68 version: packageJson.version,
69 path: "/mcp",
70}).pipe(Layer.provide(McpAuthMiddlewareLive));
71
72export const PreviewToolkitRegistrationLive = Layer.effectDiscard(
73 Effect.gen(function* () {
74 const server = yield* McpServer.McpServer;
75 const built = yield* PreviewToolkit;
76 const handleTool = built.handle as unknown as (
77 name: keyof typeof built.tools,
78 payload: unknown,
79 ) => Effect.Effect<
80 Stream.Stream<{ readonly encodedResult: unknown }, Error>,
81 Error,
82 McpInvocationContext
83 >;
84 for (const tool of Object.values(built.tools)) {
85 yield* server.addTool({
86 tool: new McpSchema.Tool({
87 name: tool.name,
88 description: Tool.getDescription(tool),
89 inputSchema: Tool.getJsonSchema(tool),
90 annotations: {
91 ...Context.getOption(tool.annotations, Tool.Title).pipe(
92 Option.map((title) => ({ title })),
93 Option.getOrUndefined,
94 ),
95 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
96 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
97 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
98 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
99 },
100 }),
101 annotations: tool.annotations,
102 handle: (payload) =>
103 handleTool(tool.name as keyof typeof built.tools, payload).pipe(
104 Stream.unwrap,
105 Stream.run(Sink.last()),
106 Effect.flatMap(Effect.fromOption),
107 Effect.matchCause({
108 onFailure: (cause) =>
109 new McpSchema.CallToolResult({
110 isError: true,
111 content: [{ type: "text", text: Cause.pretty(cause) }],
112 }),
113 onSuccess: (result) => {
114 if (tool.name === "preview_snapshot") {
115 const snapshot = result.encodedResult as {
116 readonly screenshot: {
117 readonly mimeType: "image/png";
118 readonly data: string;
119 readonly width: number;
120 readonly height: number;
121 };
122 readonly [key: string]: unknown;
123 };
124 const { screenshot, ...page } = snapshot;
125 const metadata = {
126 ...page,
127 screenshot: {
128 mimeType: screenshot.mimeType,
129 width: screenshot.width,
130 height: screenshot.height,
131 },
132 };
133 return new McpSchema.CallToolResult({
134 isError: false,
135 structuredContent: metadata,
136 content: [
137 { type: "text", text: JSON.stringify(metadata) },
138 {
139 type: "image",
140 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
141 mimeType: screenshot.mimeType,
142 },
143 ],
144 });
145 }
146 return new McpSchema.CallToolResult({
147 isError: false,
148 structuredContent:
149 typeof result.encodedResult === "object" ? result.encodedResult : undefined,
150 content: [{ type: "text", text: JSON.stringify(result.encodedResult) }],
151 });
152 },
153 }),
154 ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>,
155 });
156 }
157 }),
158).pipe(Layer.provide(PreviewToolkitHandlersLive));
159
160export const McpHttpServerLive = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe(
161 Layer.provideMerge(McpTransportLive),
162);
apps/server/src/mcp/Layers/McpSessionRegistry.test.ts created+66
......@@ -0,0 +1,66 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import { HttpServer } from "effect/unstable/http";
6
7import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts";
8import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts";
9
10const environmentId = EnvironmentId.make("environment-1");
11const fakeHttpServer = HttpServer.HttpServer.of({
12 address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 },
13 serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"],
14});
15const fakeEnvironment = ServerEnvironment.of({
16 getEnvironmentId: Effect.succeed(environmentId),
17 getDescriptor: Effect.die("unused"),
18});
19
20const makeRegistry = (now: () => number) =>
21 makeMcpSessionRegistry({
22 now,
23 idleTimeoutMs: 100,
24 maximumLifetimeMs: 1_000,
25 }).pipe(
26 Effect.provideService(HttpServer.HttpServer, fakeHttpServer),
27 Effect.provideService(ServerEnvironment, fakeEnvironment),
28 Effect.provide(NodeServices.layer),
29 );
30
31it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () =>
32 Effect.gen(function* () {
33 let timestamp = 1_000;
34 const registry = yield* makeRegistry(() => timestamp);
35 const threadId = ThreadId.make("thread-1");
36 const issued = yield* registry.issue({
37 threadId,
38 providerInstanceId: ProviderInstanceId.make("codex"),
39 });
40 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
41 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
42 expect(token.length).toBeGreaterThan(20);
43
44 const resolved = yield* registry.resolve(token);
45 expect(resolved?.threadId).toBe(threadId);
46
47 yield* registry.revokeThread(threadId);
48 expect(yield* registry.resolve(token)).toBeUndefined();
49
50 timestamp += 2_000;
51 }),
52);
53
54it.effect("expires credentials after inactivity", () =>
55 Effect.gen(function* () {
56 let timestamp = 1_000;
57 const registry = yield* makeRegistry(() => timestamp);
58 const issued = yield* registry.issue({
59 threadId: ThreadId.make("thread-2"),
60 providerInstanceId: ProviderInstanceId.make("claude"),
61 });
62 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
63 timestamp += 101;
64 expect(yield* registry.resolve(token)).toBeUndefined();
65 }),
66);
apps/server/src/mcp/Layers/McpSessionRegistry.ts created+173
......@@ -0,0 +1,173 @@
1import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6import { HttpServer } from "effect/unstable/http";
7
8import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts";
9import type { McpInvocationScope } from "../Services/McpInvocationContext.ts";
10import {
11 McpSessionRegistry,
12 type McpCredentialRequest,
13 type McpIssuedCredential,
14 type McpSessionRegistryShape,
15} from "../Services/McpSessionRegistry.ts";
16
17interface CredentialRecord {
18 readonly tokenHash: string;
19 readonly scope: McpInvocationScope;
20 readonly lastUsedAt: number;
21}
22
23interface RegistryState {
24 readonly records: ReadonlyMap<string, CredentialRecord>;
25}
26
27export interface McpSessionRegistryOptions {
28 readonly idleTimeoutMs?: number;
29 readonly maximumLifetimeMs?: number;
30 readonly now?: () => number;
31}
32
33const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000;
34const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000;
35
36const bytesToHex = (bytes: Uint8Array): string =>
37 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
38
39const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
40
41export const makeMcpSessionRegistry = (options: McpSessionRegistryOptions = {}) =>
42 Effect.gen(function* () {
43 const crypto = yield* Crypto.Crypto;
44 const environment = yield* ServerEnvironment;
45 const environmentId = yield* environment.getEnvironmentId;
46 const httpServer = yield* HttpServer.HttpServer;
47 const state = yield* Ref.make<RegistryState>({ records: new Map() });
48 const now = options.now ?? Date.now;
49 const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
50 const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS;
51 const endpoint =
52 httpServer.address._tag === "TcpAddress"
53 ? `http://127.0.0.1:${httpServer.address.port}/mcp`
54 : "http://127.0.0.1/mcp";
55
56 const hashToken = (token: string) =>
57 crypto
58 .digest("SHA-256", new TextEncoder().encode(token))
59 .pipe(Effect.map(bytesToHex), Effect.orDie);
60
61 const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {
62 let changed = false;
63 const next = new Map<string, CredentialRecord>();
64 for (const [hash, record] of records) {
65 if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) {
66 next.set(hash, record);
67 } else {
68 changed = true;
69 }
70 }
71 return changed ? next : records;
72 };
73
74 const issue: McpSessionRegistryShape["issue"] = (request) =>
75 Effect.gen(function* () {
76 const issuedAt = now();
77 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
78 const rawToken = yield* crypto
79 .randomBytes(32)
80 .pipe(Effect.map(tokenFromBytes), Effect.orDie);
81 const tokenHash = yield* hashToken(rawToken);
82 const expiresAt = issuedAt + maximumLifetimeMs;
83 const scope: McpInvocationScope = {
84 environmentId,
85 threadId: ThreadId.make(request.threadId),
86 providerSessionId,
87 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
88 capabilities: new Set(["preview"]),
89 issuedAt,
90 expiresAt,
91 };
92 yield* Ref.update(state, ({ records }) => {
93 const next = new Map(pruneExpired(records, issuedAt));
94 next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt });
95 return { records: next };
96 });
97 return {
98 config: {
99 environmentId,
100 threadId: scope.threadId,
101 providerSessionId,
102 providerInstanceId: scope.providerInstanceId,
103 endpoint,
104 authorizationHeader: `Bearer ${rawToken}`,
105 },
106 expiresAt,
107 };
108 });
109
110 const resolve: McpSessionRegistryShape["resolve"] = (rawToken) =>
111 Effect.gen(function* () {
112 if (rawToken.length === 0) return undefined;
113 const tokenHash = yield* hashToken(rawToken);
114 const timestamp = now();
115 let resolved: McpInvocationScope | undefined;
116 yield* Ref.update(state, ({ records }) => {
117 const current = pruneExpired(records, timestamp);
118 const record = current.get(tokenHash);
119 if (!record) return { records: current };
120 resolved = record.scope;
121 const next = new Map(current);
122 next.set(tokenHash, { ...record, lastUsedAt: timestamp });
123 return { records: next };
124 });
125 return resolved;
126 });
127
128 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>
129 Ref.update(state, ({ records }) => ({
130 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),
131 }));
132
133 return McpSessionRegistry.of({
134 issue,
135 resolve,
136 revokeProviderSession: (providerSessionId) =>
137 revokeWhere((record) => record.scope.providerSessionId === providerSessionId),
138 revokeThread: (threadId) => revokeWhere((record) => record.scope.threadId === threadId),
139 revokeAll: Ref.set(state, { records: new Map() }),
140 });
141 });
142
143let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;
144
145export const McpSessionRegistryLive: Layer.Layer<
146 McpSessionRegistry,
147 never,
148 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
149> = Layer.effect(
150 McpSessionRegistry,
151 makeMcpSessionRegistry().pipe(
152 Effect.tap((registry) =>
153 Effect.sync(() => {
154 activeMcpSessionRegistry = registry;
155 }),
156 ),
157 ),
158);
159
160export const issueActiveMcpCredential = (
161 request: McpCredentialRequest,
162): Effect.Effect<McpIssuedCredential | undefined> =>
163 activeMcpSessionRegistry
164 ? activeMcpSessionRegistry
165 .revokeThread(request.threadId)
166 .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request)))
167 : Effect.sync((): McpIssuedCredential | undefined => undefined);
168
169export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
170 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
171
172export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>
173 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void;
apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts created+65
......@@ -0,0 +1,65 @@
1import { expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 PreviewAutomationNoFocusedOwnerError,
5 ProviderInstanceId,
6 ThreadId,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Stream from "effect/Stream";
10
11import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts";
12
13const scope = {
14 environmentId: EnvironmentId.make("environment-1"),
15 threadId: ThreadId.make("thread-1"),
16 providerSessionId: "provider-session-1",
17 providerInstanceId: ProviderInstanceId.make("codex"),
18 capabilities: new Set(["preview"] as const),
19 issuedAt: 1,
20 expiresAt: 2,
21};
22
23it.effect("routes a request to the focused owner and correlates its response", () =>
24 Effect.scoped(
25 Effect.gen(function* () {
26 const broker = makePreviewAutomationBroker();
27 const requests = yield* broker.connect("client-1");
28 yield* Stream.runForEach(requests, (request) =>
29 broker.respond({
30 requestId: request.requestId,
31 ok: true,
32 result: { available: true },
33 }),
34 ).pipe(Effect.forkScoped);
35 yield* Effect.yieldNow;
36 yield* broker.reportOwner({
37 clientId: "client-1",
38 environmentId: scope.environmentId,
39 threadId: scope.threadId,
40 tabId: null,
41 visible: false,
42 supportsAutomation: true,
43 focusedAt: "2026-06-11T00:00:00.000Z",
44 });
45
46 const result = yield* broker.invoke<{ available: boolean }>({
47 scope,
48 operation: "open",
49 input: {},
50 });
51
52 expect(result).toEqual({ available: true });
53 }),
54 ),
55);
56
57it.effect("rejects calls when no focused owner exists", () =>
58 Effect.gen(function* () {
59 const broker = makePreviewAutomationBroker();
60 const error = yield* broker
61 .invoke<void>({ scope, operation: "status", input: {} })
62 .pipe(Effect.flip);
63 expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError);
64 }),
65);
apps/server/src/mcp/Layers/PreviewAutomationBroker.ts created+268
......@@ -0,0 +1,268 @@
1import {
2 PreviewAutomationExecutionError,
3 PreviewAutomationInvalidSelectorError,
4 PreviewAutomationNoFocusedOwnerError,
5 PreviewAutomationResultTooLargeError,
6 PreviewAutomationTabNotFoundError,
7 PreviewAutomationTimeoutError,
8 PreviewAutomationUnavailableError,
9 PreviewAutomationUnsupportedClientError,
10 type PreviewAutomationError,
11 type PreviewAutomationOwner,
12 type PreviewAutomationResponse,
13} from "@t3tools/contracts";
14import * as Deferred from "effect/Deferred";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Queue from "effect/Queue";
19import * as Ref from "effect/Ref";
20import * as Stream from "effect/Stream";
21
22import {
23 PreviewAutomationBroker,
24 type PreviewAutomationBrokerShape,
25} from "../Services/PreviewAutomationBroker.ts";
26
27interface ClientConnection {
28 readonly clientId: string;
29 readonly queue: Queue.Queue<
30 Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never
31 ? never
32 : import("@t3tools/contracts").PreviewAutomationRequest
33 >;
34}
35
36interface PendingRequest {
37 readonly clientId: string;
38 readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>;
39}
40
41interface BrokerState {
42 readonly clients: ReadonlyMap<string, ClientConnection>;
43 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;
44 readonly pending: ReadonlyMap<string, PendingRequest>;
45}
46
47const makeResponseError = (
48 error: NonNullable<PreviewAutomationResponse["error"]>,
49): PreviewAutomationError => {
50 switch (error._tag) {
51 case "PreviewAutomationNoFocusedOwnerError":
52 return new PreviewAutomationNoFocusedOwnerError({ message: error.message });
53 case "PreviewAutomationUnsupportedClientError":
54 return new PreviewAutomationUnsupportedClientError({ message: error.message });
55 case "PreviewAutomationTabNotFoundError":
56 return new PreviewAutomationTabNotFoundError({ message: error.message });
57 case "PreviewAutomationTimeoutError":
58 return new PreviewAutomationTimeoutError({ message: error.message });
59 case "PreviewAutomationInvalidSelectorError": {
60 const detail =
61 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
62 return new PreviewAutomationInvalidSelectorError({
63 message: error.message,
64 selector:
65 detail && "selector" in detail && typeof detail.selector === "string"
66 ? detail.selector
67 : "",
68 });
69 }
70 case "PreviewAutomationResultTooLargeError": {
71 const detail =
72 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
73 return new PreviewAutomationResultTooLargeError({
74 message: error.message,
75 maximumBytes:
76 detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number"
77 ? detail.maximumBytes
78 : 64_000,
79 });
80 }
81 case "PreviewAutomationUnavailableError":
82 return new PreviewAutomationUnavailableError({ message: error.message });
83 default:
84 return new PreviewAutomationExecutionError({
85 message: error.message,
86 detail: error.detail,
87 });
88 }
89};
90
91export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => {
92 const state = Effect.runSync(
93 Ref.make<BrokerState>({
94 clients: new Map(),
95 owners: new Map(),
96 pending: new Map(),
97 }),
98 );
99 let requestSequence = 0;
100
101 const disconnect = (clientId: string, queue: ClientConnection["queue"]) =>
102 Effect.gen(function* () {
103 const toFail: PendingRequest[] = [];
104 yield* Ref.update(state, (current) => {
105 if (current.clients.get(clientId)?.queue !== queue) return current;
106 const clients = new Map(current.clients);
107 const owners = new Map(current.owners);
108 const pending = new Map(current.pending);
109 clients.delete(clientId);
110 owners.delete(clientId);
111 for (const [requestId, entry] of pending) {
112 if (entry.clientId === clientId) {
113 pending.delete(requestId);
114 toFail.push(entry);
115 }
116 }
117 return { clients, owners, pending };
118 });
119 yield* Effect.forEach(
120 toFail,
121 ({ deferred }) =>
122 Deferred.fail(
123 deferred,
124 new PreviewAutomationUnavailableError({
125 message: "The preview automation client disconnected.",
126 }),
127 ),
128 { discard: true },
129 );
130 yield* Queue.shutdown(queue);
131 });
132
133 const connect: PreviewAutomationBrokerShape["connect"] = (clientId) =>
134 Effect.gen(function* () {
135 const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();
136 let previous: ClientConnection | undefined;
137 yield* Ref.update(state, (current) => {
138 previous = current.clients.get(clientId);
139 const clients = new Map(current.clients);
140 clients.set(clientId, { clientId, queue });
141 return { ...current, clients };
142 });
143 if (previous) yield* disconnect(clientId, previous.queue);
144 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
145 });
146
147 const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = (owner) =>
148 Ref.update(state, (current) => {
149 const owners = new Map(current.owners);
150 owners.set(owner.clientId, owner);
151 return { ...current, owners };
152 });
153
154 const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = (clientId) =>
155 Ref.update(state, (current) => {
156 const owners = new Map(current.owners);
157 owners.delete(clientId);
158 return { ...current, owners };
159 });
160
161 const respond: PreviewAutomationBrokerShape["respond"] = (response) =>
162 Effect.gen(function* () {
163 let pending: PendingRequest | undefined;
164 yield* Ref.update(state, (current) => {
165 pending = current.pending.get(response.requestId);
166 if (!pending) return current;
167 const next = new Map(current.pending);
168 next.delete(response.requestId);
169 return { ...current, pending: next };
170 });
171 if (!pending) return;
172 if (response.ok) {
173 yield* Deferred.succeed(pending.deferred, response.result);
174 } else {
175 yield* Deferred.fail(
176 pending.deferred,
177 response.error
178 ? makeResponseError(response.error)
179 : new PreviewAutomationExecutionError({
180 message: "Preview automation failed without an error payload.",
181 }),
182 );
183 }
184 });
185
186 const invoke = <A = unknown>(
187 input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0],
188 ): Effect.Effect<A, PreviewAutomationError> =>
189 Effect.gen(function* () {
190 const current = yield* Ref.get(state);
191 const candidates = Array.from(current.owners.values())
192 .filter(
193 (owner) =>
194 owner.environmentId === input.scope.environmentId &&
195 owner.threadId === input.scope.threadId &&
196 owner.supportsAutomation &&
197 (input.operation === "open" || input.operation === "status" || owner.visible),
198 )
199 .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt));
200 const owner = candidates[0];
201 if (!owner) {
202 return yield* new PreviewAutomationNoFocusedOwnerError({
203 message: "No focused desktop preview owner is available for this thread.",
204 });
205 }
206 const connection = current.clients.get(owner.clientId);
207 if (!connection) {
208 return yield* new PreviewAutomationUnavailableError({
209 message: "The focused preview owner is not connected.",
210 });
211 }
212 if (
213 input.operation !== "open" &&
214 input.operation !== "status" &&
215 !owner.tabId &&
216 !input.tabId
217 ) {
218 return yield* new PreviewAutomationTabNotFoundError({
219 message: "The focused preview owner does not have an active tab.",
220 });
221 }
222 const requestId = `preview-${requestSequence++}`;
223 const timeoutMs = input.timeoutMs ?? 15_000;
224 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
225 yield* Ref.update(state, (next) => {
226 const pending = new Map(next.pending);
227 pending.set(requestId, { clientId: owner.clientId, deferred });
228 return { ...next, pending };
229 });
230 const offered = yield* Queue.offer(connection.queue, {
231 requestId,
232 threadId: input.scope.threadId,
233 tabId: input.tabId ?? owner.tabId ?? undefined,
234 operation: input.operation,
235 input: input.input,
236 timeoutMs,
237 });
238 if (!offered) {
239 return yield* new PreviewAutomationUnavailableError({
240 message: "The preview automation client is no longer accepting requests.",
241 });
242 }
243 const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs));
244 yield* Ref.update(state, (next) => {
245 const pending = new Map(next.pending);
246 pending.delete(requestId);
247 return { ...next, pending };
248 });
249 return yield* Option.match(result, {
250 onNone: () =>
251 Effect.fail(
252 new PreviewAutomationTimeoutError({
253 message: `Preview automation timed out after ${timeoutMs}ms.`,
254 }),
255 ),
256 onSome: (value) => Effect.succeed(value as A),
257 });
258 });
259
260 return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke });
261};
262
263export const previewAutomationBroker = makePreviewAutomationBroker();
264
265export const PreviewAutomationBrokerLive: Layer.Layer<PreviewAutomationBroker> = Layer.succeed(
266 PreviewAutomationBroker,
267 previewAutomationBroker,
268);
apps/server/src/mcp/Services/McpInvocationContext.ts created+33
......@@ -0,0 +1,33 @@
1import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import { PreviewAutomationUnavailableError } from "@t3tools/contracts";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5
6export type McpCapability = "preview";
7
8export interface McpInvocationScope {
9 readonly environmentId: EnvironmentId;
10 readonly threadId: ThreadId;
11 readonly providerSessionId: string;
12 readonly providerInstanceId: ProviderInstanceId;
13 readonly capabilities: ReadonlySet<McpCapability>;
14 readonly issuedAt: number;
15 readonly expiresAt: number;
16}
17
18export class McpInvocationContext extends Context.Service<
19 McpInvocationContext,
20 McpInvocationScope
21>()("t3/mcp/Services/McpInvocationContext") {}
22
23export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* (
24 capability: McpCapability,
25) {
26 const invocation = yield* McpInvocationContext;
27 if (!invocation.capabilities.has(capability)) {
28 return yield* new PreviewAutomationUnavailableError({
29 message: `MCP credential does not grant the ${capability} capability.`,
30 });
31 }
32 return invocation;
33});
apps/server/src/mcp/Services/McpProviderSession.ts created+34
......@@ -0,0 +1,34 @@
1import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3
4export interface McpProviderSessionConfig {
5 readonly environmentId: EnvironmentId;
6 readonly threadId: ThreadId;
7 readonly providerSessionId: string;
8 readonly providerInstanceId: ProviderInstanceId;
9 readonly endpoint: string;
10 readonly authorizationHeader: string;
11}
12
13export class McpProviderSession extends Context.Service<
14 McpProviderSession,
15 McpProviderSessionConfig
16>()("t3/mcp/Services/McpProviderSession") {}
17
18const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
19
20export function setMcpProviderSession(config: McpProviderSessionConfig): void {
21 sessionsByThread.set(config.threadId, config);
22}
23
24export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined {
25 return sessionsByThread.get(threadId);
26}
27
28export function clearMcpProviderSession(threadId: ThreadId): void {
29 sessionsByThread.delete(threadId);
30}
31
32export function clearAllMcpProviderSessions(): void {
33 sessionsByThread.clear();
34}
apps/server/src/mcp/Services/McpSessionRegistry.ts created+29
......@@ -0,0 +1,29 @@
1import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5import type { McpInvocationScope } from "./McpInvocationContext.ts";
6import type { McpProviderSessionConfig } from "./McpProviderSession.ts";
7
8export interface McpCredentialRequest {
9 readonly threadId: ThreadId;
10 readonly providerInstanceId: ProviderInstanceId;
11}
12
13export interface McpIssuedCredential {
14 readonly config: McpProviderSessionConfig;
15 readonly expiresAt: number;
16}
17
18export interface McpSessionRegistryShape {
19 readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential, never>;
20 readonly resolve: (rawToken: string) => Effect.Effect<McpInvocationScope | undefined, never>;
21 readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>;
22 readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>;
23 readonly revokeAll: Effect.Effect<void>;
24}
25
26export class McpSessionRegistry extends Context.Service<
27 McpSessionRegistry,
28 McpSessionRegistryShape
29>()("t3/mcp/Services/McpSessionRegistry") {}
apps/server/src/mcp/Services/PreviewAutomationBroker.ts created+40
......@@ -0,0 +1,40 @@
1import type {
2 PreviewAutomationError,
3 PreviewAutomationOperation,
4 PreviewAutomationOwner,
5 PreviewAutomationRequest,
6 PreviewAutomationResponse,
7 PreviewTabId,
8} from "@t3tools/contracts";
9import * as Context from "effect/Context";
10import type * as Effect from "effect/Effect";
11import type * as Stream from "effect/Stream";
12
13import type { McpInvocationScope } from "./McpInvocationContext.ts";
14
15export interface PreviewAutomationInvokeInput {
16 readonly scope: McpInvocationScope;
17 readonly operation: PreviewAutomationOperation;
18 readonly input: unknown;
19 readonly tabId?: PreviewTabId;
20 readonly timeoutMs?: number;
21}
22
23export interface PreviewAutomationBrokerShape {
24 readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;
25 readonly reportOwner: (
26 owner: PreviewAutomationOwner,
27 ) => Effect.Effect<void, PreviewAutomationError>;
28 readonly clearOwner: (clientId: string) => Effect.Effect<void>;
29 readonly respond: (
30 response: PreviewAutomationResponse,
31 ) => Effect.Effect<void, PreviewAutomationError>;
32 readonly invoke: <A = unknown>(
33 request: PreviewAutomationInvokeInput,
34 ) => Effect.Effect<A, PreviewAutomationError>;
35}
36
37export class PreviewAutomationBroker extends Context.Service<
38 PreviewAutomationBroker,
39 PreviewAutomationBrokerShape
40>()("t3/mcp/Services/PreviewAutomationBroker") {}
apps/server/src/mcp/toolkits/preview/handlers.ts created+47
......@@ -0,0 +1,47 @@
1import * as Effect from "effect/Effect";
2import type {
3 PreviewAutomationOperation,
4 PreviewAutomationSnapshot,
5 PreviewAutomationStatus,
6} from "@t3tools/contracts";
7
8import { requireMcpCapability } from "../../Services/McpInvocationContext.ts";
9import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts";
10import { PreviewToolkit } from "./tools.ts";
11
12const invoke = <A>(
13 operation: PreviewAutomationOperation,
14 input: unknown,
15 timeoutMs?: number,
16): Effect.Effect<
17 A,
18 import("@t3tools/contracts").PreviewAutomationError,
19 import("../../Services/McpInvocationContext.ts").McpInvocationContext
20> =>
21 Effect.gen(function* () {
22 const scope = yield* requireMcpCapability("preview");
23 return yield* previewAutomationBroker.invoke<A>({
24 scope,
25 operation,
26 input,
27 ...(timeoutMs === undefined ? {} : { timeoutMs }),
28 });
29 });
30
31export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({
32 preview_status: () => invoke<PreviewAutomationStatus>("status", {}),
33 preview_open: (input) =>
34 invoke<PreviewAutomationStatus>("open", {
35 ...input,
36 show: input.show ?? true,
37 reuseExistingTab: input.reuseExistingTab ?? true,
38 }),
39 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
40 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),
41 preview_click: (input) => invoke<void>("click", input, input.timeoutMs),
42 preview_type: (input) => invoke<void>("type", input, input.timeoutMs),
43 preview_press: (input) => invoke<void>("press", input),
44 preview_scroll: (input) => invoke<void>("scroll", input),
45 preview_evaluate: (input) => invoke<unknown>("evaluate", input),
46 preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs),
47});
apps/server/src/mcp/toolkits/preview/tools.ts created+138
......@@ -0,0 +1,138 @@
1import {
2 PreviewAutomationClickInput,
3 PreviewAutomationError,
4 PreviewAutomationEvaluateInput,
5 PreviewAutomationNavigateInput,
6 PreviewAutomationOpenInput,
7 PreviewAutomationPressInput,
8 PreviewAutomationScrollInput,
9 PreviewAutomationSnapshot,
10 PreviewAutomationStatus,
11 PreviewAutomationTypeInput,
12 PreviewAutomationWaitForInput,
13} from "@t3tools/contracts";
14import * as Schema from "effect/Schema";
15import { Tool, Toolkit } from "effect/unstable/ai";
16
17import { McpInvocationContext } from "../../Services/McpInvocationContext.ts";
18
19const browserTool = <T extends Tool.Any>(tool: T): T =>
20 tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T;
21
22export const PreviewStatusTool = Tool.make("preview_status", {
23 description:
24 "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.",
25 success: PreviewAutomationStatus,
26 failure: PreviewAutomationError,
27 dependencies: [McpInvocationContext],
28})
29 .annotate(Tool.Title, "Get preview status")
30 .annotate(Tool.Readonly, true)
31 .annotate(Tool.Destructive, false)
32 .annotate(Tool.Idempotent, true);
33
34export const PreviewOpenTool = browserTool(
35 Tool.make("preview_open", {
36 description:
37 "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.",
38 parameters: PreviewAutomationOpenInput,
39 success: PreviewAutomationStatus,
40 failure: PreviewAutomationError,
41 dependencies: [McpInvocationContext],
42 }).annotate(Tool.Title, "Open browser preview"),
43);
44
45export const PreviewNavigateTool = browserTool(
46 Tool.make("preview_navigate", {
47 description:
48 "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.",
49 parameters: PreviewAutomationNavigateInput,
50 success: PreviewAutomationStatus,
51 failure: PreviewAutomationError,
52 dependencies: [McpInvocationContext],
53 }).annotate(Tool.Title, "Navigate browser preview"),
54);
55
56export const PreviewSnapshotTool = Tool.make("preview_snapshot", {
57 description:
58 "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.",
59 success: PreviewAutomationSnapshot,
60 failure: PreviewAutomationError,
61 dependencies: [McpInvocationContext],
62})
63 .annotate(Tool.Title, "Capture preview snapshot")
64 .annotate(Tool.Readonly, true)
65 .annotate(Tool.Destructive, false);
66
67export const PreviewClickTool = browserTool(
68 Tool.make("preview_click", {
69 description:
70 "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.",
71 parameters: PreviewAutomationClickInput,
72 failure: PreviewAutomationError,
73 dependencies: [McpInvocationContext],
74 }).annotate(Tool.Title, "Click preview page"),
75);
76
77export const PreviewTypeTool = browserTool(
78 Tool.make("preview_type", {
79 description:
80 "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.",
81 parameters: PreviewAutomationTypeInput,
82 failure: PreviewAutomationError,
83 dependencies: [McpInvocationContext],
84 }).annotate(Tool.Title, "Type into preview page"),
85);
86
87export const PreviewPressTool = browserTool(
88 Tool.make("preview_press", {
89 description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.",
90 parameters: PreviewAutomationPressInput,
91 failure: PreviewAutomationError,
92 dependencies: [McpInvocationContext],
93 }).annotate(Tool.Title, "Press key in preview page"),
94);
95
96export const PreviewScrollTool = browserTool(
97 Tool.make("preview_scroll", {
98 description:
99 "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.",
100 parameters: PreviewAutomationScrollInput,
101 failure: PreviewAutomationError,
102 dependencies: [McpInvocationContext],
103 }).annotate(Tool.Title, "Scroll preview page"),
104);
105
106export const PreviewEvaluateTool = browserTool(
107 Tool.make("preview_evaluate", {
108 description:
109 "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.",
110 parameters: PreviewAutomationEvaluateInput,
111 success: Schema.Unknown,
112 failure: PreviewAutomationError,
113 dependencies: [McpInvocationContext],
114 }).annotate(Tool.Title, "Evaluate JavaScript in preview"),
115);
116
117export const PreviewWaitForTool = browserTool(
118 Tool.make("preview_wait_for", {
119 description:
120 "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.",
121 parameters: PreviewAutomationWaitForInput,
122 failure: PreviewAutomationError,
123 dependencies: [McpInvocationContext],
124 }).annotate(Tool.Title, "Wait for preview page condition"),
125);
126
127export const PreviewToolkit = Toolkit.make(
128 PreviewStatusTool,
129 PreviewOpenTool,
130 PreviewNavigateTool,
131 PreviewSnapshotTool,
132 PreviewClickTool,
133 PreviewTypeTool,
134 PreviewPressTool,
135 PreviewScrollTool,
136 PreviewEvaluateTool,
137 PreviewWaitForTool,
138);
apps/server/src/provider/Layers/ClaudeAdapter.ts+15
......@@ -69,6 +69,7 @@ import * as Stream from "effect/Stream";
6969
7070import { resolveAttachmentPath } from "../../attachmentStore.ts";
7171import { ServerConfig } from "../../config.ts";
72import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";
7273import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
7374import {
7475 getClaudeModelCapabilities,
......@@ -3445,6 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
34453446 ...(fastMode ? { fastMode: true } : {}),
34463447 ...(ultracode ? { ultracode: true } : {}),
34473448 };
3449 const mcpSession = readMcpProviderSession(input.threadId);
34483450 const queryOptions: ClaudeQueryOptions = {
34493451 ...(input.cwd ? { cwd: input.cwd } : {}),
34503452 ...(apiModelId ? { model: apiModelId } : {}),
......@@ -3470,6 +3472,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
34703472 env: claudeEnvironment,
34713473 ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
34723474 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
3475 ...(mcpSession
3476 ? {
3477 mcpServers: {
3478 "t3-code": {
3479 type: "http",
3480 url: mcpSession.endpoint,
3481 headers: {
3482 Authorization: mcpSession.authorizationHeader,
3483 },
3484 },
3485 },
3486 }
3487 : {}),
34733488 };
34743489
34753490 yield* Effect.annotateCurrentSpan({
apps/server/src/provider/Layers/CodexAdapter.ts+16
......@@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema";
3939
4040import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
4141import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
42import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";
4243
4344import {
4445 ProviderAdapterRequestError,
......@@ -1382,6 +1383,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
13821383 input.modelSelection?.instanceId === boundInstanceId
13831384 ? getCodexServiceTierOptionValue(input.modelSelection)
13841385 : undefined;
1386 const mcpSession = readMcpProviderSession(input.threadId);
13851387 const runtimeInput: CodexSessionRuntimeOptions = {
13861388 threadId: input.threadId,
13871389 providerInstanceId: boundInstanceId,
......@@ -1397,6 +1399,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
13971399 ? { model: input.modelSelection.model }
13981400 : {}),
13991401 ...(serviceTier ? { serviceTier } : {}),
1402 ...(mcpSession
1403 ? {
1404 environment: {
1405 ...(options?.environment ?? process.env),
1406 T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),
1407 },
1408 appServerArgs: [
1409 "-c",
1410 `mcp_servers.t3-code.url=${mcpSession.endpoint}`,
1411 "-c",
1412 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
1413 ],
1414 }
1415 : {}),
14001416 };
14011417 const sessionScope = yield* Scope.make("sequential");
14021418 let sessionScopeTransferred = false;
apps/server/src/provider/Layers/CodexSessionRuntime.ts+2-1
......@@ -103,6 +103,7 @@ export interface CodexSessionRuntimeOptions {
103103 readonly model?: string;
104104 readonly serviceTier?: CodexServiceTier | undefined;
105105 readonly resumeCursor?: CodexResumeCursor;
106 readonly appServerArgs?: ReadonlyArray<string>;
106107}
107108
108109export interface CodexSessionRuntimeSendTurnInput {
......@@ -720,7 +721,7 @@ export const makeCodexSessionRuntime = (
720721 };
721722 const child = yield* spawner
722723 .spawn(
723 ChildProcess.make(options.binaryPath, ["app-server"], {
724 ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], {
724725 cwd: options.cwd,
725726 env,
726727 forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER,
apps/server/src/provider/Layers/CursorAdapter.ts+19
......@@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
4242
4343import { resolveAttachmentPath } from "../../attachmentStore.ts";
4444import { ServerConfig } from "../../config.ts";
45import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";
4546import {
4647 ProviderAdapterProcessError,
4748 ProviderAdapterRequestError,
......@@ -530,6 +531,7 @@ export function makeCursorAdapter(
530531 ? yield* options.resolveSettings
531532 : cursorSettings;
532533
534 const mcpSession = readMcpProviderSession(input.threadId);
533535 const acp = yield* makeCursorAcpRuntime({
534536 cursorSettings: effectiveCursorSettings,
535537 ...(options?.environment ? { environment: options.environment } : {}),
......@@ -537,6 +539,23 @@ export function makeCursorAdapter(
537539 cwd,
538540 ...(resumeSessionId ? { resumeSessionId } : {}),
539541 clientInfo: { name: "t3-code", version: "0.0.0" },
542 ...(mcpSession
543 ? {
544 mcpServers: [
545 {
546 type: "http" as const,
547 name: "t3-code",
548 url: mcpSession.endpoint,
549 headers: [
550 {
551 name: "Authorization",
552 value: mcpSession.authorizationHeader,
553 },
554 ],
555 },
556 ],
557 }
558 : {}),
540559 ...acpNativeLoggers,
541560 }).pipe(
542561 Effect.provideService(Scope.Scope, sessionScope),
apps/server/src/provider/Layers/GrokAdapter.ts+19
......@@ -33,6 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
3333
3434import { resolveAttachmentPath } from "../../attachmentStore.ts";
3535import { ServerConfig } from "../../config.ts";
36import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";
3637import {
3738 ProviderAdapterProcessError,
3839 ProviderAdapterRequestError,
......@@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
374375 threadId: input.threadId,
375376 });
376377
378 const mcpSession = readMcpProviderSession(input.threadId);
377379 const acp = yield* makeGrokAcpRuntime({
378380 grokSettings,
379381 ...(options?.environment ? { environment: options.environment } : {}),
......@@ -381,6 +383,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
381383 cwd,
382384 ...(resumeSessionId ? { resumeSessionId } : {}),
383385 clientInfo: { name: "t3-code", version: "0.0.0" },
386 ...(mcpSession
387 ? {
388 mcpServers: [
389 {
390 type: "http" as const,
391 name: "t3-code",
392 url: mcpSession.endpoint,
393 headers: [
394 {
395 name: "Authorization",
396 value: mcpSession.authorizationHeader,
397 },
398 ],
399 },
400 ],
401 }
402 : {}),
384403 ...acpNativeLoggers,
385404 }).pipe(
386405 Effect.provideService(Scope.Scope, sessionScope),
apps/server/src/provider/Layers/OpenCodeAdapter.ts+17
......@@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
2626
2727import { resolveAttachmentPath } from "../../attachmentStore.ts";
2828import { ServerConfig } from "../../config.ts";
29import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";
2930import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3031import {
3132 ProviderAdapterProcessError,
......@@ -1053,6 +1054,22 @@ export function makeOpenCodeAdapter(
10531054 directory,
10541055 ...(server.external && serverPassword ? { serverPassword } : {}),
10551056 });
1057 const mcpSession = readMcpProviderSession(input.threadId);
1058 if (mcpSession && !server.external) {
1059 yield* runOpenCodeSdk("mcp.add", () =>
1060 client.mcp.add({
1061 name: "t3-code",
1062 config: {
1063 type: "remote",
1064 url: mcpSession.endpoint,
1065 headers: {
1066 Authorization: mcpSession.authorizationHeader,
1067 },
1068 oauth: false,
1069 },
1070 }),
1071 );
1072 }
10561073 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
10571074 client.session.create({
10581075 title: `T3 Code ${input.threadId}`,
apps/server/src/provider/Layers/ProviderService.ts+46-15
......@@ -56,6 +56,16 @@ import {
5656import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
5757import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";
5858import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
59import {
60 issueActiveMcpCredential,
61 revokeActiveMcpThread,
62 revokeAllActiveMcpCredentials,
63} from "../../mcp/Layers/McpSessionRegistry.ts";
64import {
65 clearAllMcpProviderSessions,
66 clearMcpProviderSession,
67 setMcpProviderSession,
68} from "../../mcp/Services/McpProviderSession.ts";
5969const isModelSelection = Schema.is(ModelSelection);
6070
6171/**
......@@ -212,6 +222,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
212222 const directory = yield* ProviderSessionDirectory;
213223 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
214224 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
225 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
226 issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
227 Effect.tap((credential) =>
228 credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void,
229 ),
230 );
231 const clearMcpSession = (threadId: ThreadId) =>
232 revokeActiveMcpThread(threadId).pipe(
233 Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))),
234 );
215235
216236 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
217237 Effect.succeed(event).pipe(
......@@ -383,16 +403,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
383403 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
384404 const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload);
385405
386 const resumed = yield* adapter.startSession({
387 threadId: input.binding.threadId,
388 provider: input.binding.provider,
389 providerInstanceId: bindingInstanceId,
390 ...(persistedCwd ? { cwd: persistedCwd } : {}),
391 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
392 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
393 runtimeMode: input.binding.runtimeMode ?? "full-access",
394 });
406 yield* prepareMcpSession(input.binding.threadId, bindingInstanceId);
407 const resumed = yield* adapter
408 .startSession({
409 threadId: input.binding.threadId,
410 provider: input.binding.provider,
411 providerInstanceId: bindingInstanceId,
412 ...(persistedCwd ? { cwd: persistedCwd } : {}),
413 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
414 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
415 runtimeMode: input.binding.runtimeMode ?? "full-access",
416 })
417 .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId)));
395418 if (resumed.provider !== adapter.provider) {
419 yield* clearMcpSession(input.binding.threadId);
396420 return yield* toValidationError(
397421 input.operation,
398422 `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`,
......@@ -572,14 +596,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
572596 "provider.cwd.effective": effectiveCwd ?? "",
573597 });
574598 const adapter = yield* registry.getByInstance(resolvedInstanceId);
575 const session = yield* adapter.startSession({
576 ...input,
577 providerInstanceId: resolvedInstanceId,
578 ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
579 ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}),
580 });
599 yield* prepareMcpSession(threadId, resolvedInstanceId);
600 const session = yield* adapter
601 .startSession({
602 ...input,
603 providerInstanceId: resolvedInstanceId,
604 ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
605 ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}),
606 })
607 .pipe(Effect.onError(() => clearMcpSession(threadId)));
581608
582609 if (session.provider !== adapter.provider) {
610 yield* clearMcpSession(threadId);
583611 return yield* toValidationError(
584612 "ProviderService.startSession",
585613 `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`,
......@@ -827,6 +855,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
827855 if (routed.isActive) {
828856 yield* routed.adapter.stopSession(routed.threadId);
829857 }
858 yield* clearMcpSession(input.threadId);
830859 yield* directory.upsert({
831860 threadId: input.threadId,
832861 provider: routed.adapter.provider,
......@@ -998,6 +1027,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
9981027 ),
9991028 ).pipe(Effect.asVoid);
10001029 yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid);
1030 yield* revokeAllActiveMcpCredentials();
1031 clearAllMcpProviderSessions();
10011032 const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => []));
10021033 yield* Effect.forEach(bindings, (binding) =>
10031034 Effect.gen(function* () {
apps/server/src/provider/acp/AcpSessionRuntime.ts+4-3
......@@ -47,6 +47,7 @@ export interface AcpSessionRuntimeOptions {
4747 readonly version: string;
4848 };
4949 readonly authMethodId: string;
50 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
5051 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
5152 readonly protocolLogging?: {
5253 readonly logIncoming?: boolean;
......@@ -400,7 +401,7 @@ const makeAcpSessionRuntime = (
400401 const loadPayload = {
401402 sessionId: options.resumeSessionId,
402403 cwd: options.cwd,
403 mcpServers: [],
404 mcpServers: options.mcpServers ?? [],
404405 } satisfies EffectAcpSchema.LoadSessionRequest;
405406 const resumed = yield* runLoggedRequest(
406407 "session/load",
......@@ -413,7 +414,7 @@ const makeAcpSessionRuntime = (
413414 } else {
414415 const createPayload = {
415416 cwd: options.cwd,
416 mcpServers: [],
417 mcpServers: options.mcpServers ?? [],
417418 } satisfies EffectAcpSchema.NewSessionRequest;
418419 const created = yield* runLoggedRequest(
419420 "session/new",
......@@ -426,7 +427,7 @@ const makeAcpSessionRuntime = (
426427 } else {
427428 const createPayload = {
428429 cwd: options.cwd,
429 mcpServers: [],
430 mcpServers: options.mcpServers ?? [],
430431 } satisfies EffectAcpSchema.NewSessionRequest;
431432 const created = yield* runLoggedRequest(
432433 "session/new",
apps/server/src/server.ts+16-11
......@@ -37,6 +37,8 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide
3737import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
3838import { PreviewManagerLive } from "./preview/Layers/Manager.ts";
3939import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts";
40import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts";
41import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts";
4042import * as ProcessRunner from "./processRunner.ts";
4143import * as GitManager from "./git/GitManager.ts";
4244import { KeybindingsLive } from "./keybindings.ts";
......@@ -335,18 +337,21 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
335337);
336338
337339export const makeRoutesLayer = Layer.mergeAll(
338 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
339 Layer.provide(authHttpApiLayer),
340 Layer.provide(connectHttpApiLayer),
341 Layer.provide(orchestrationHttpApiLayer),
342 Layer.provide(serverEnvironmentHttpApiLayer),
343 Layer.provide(environmentAuthenticatedAuthLayer),
340 Layer.mergeAll(
341 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
342 Layer.provide(authHttpApiLayer),
343 Layer.provide(connectHttpApiLayer),
344 Layer.provide(orchestrationHttpApiLayer),
345 Layer.provide(serverEnvironmentHttpApiLayer),
346 Layer.provide(environmentAuthenticatedAuthLayer),
347 ),
348 attachmentsRouteLayer,
349 otlpTracesProxyRouteLayer,
350 projectFaviconRouteLayer,
351 staticAndDevRouteLayer,
352 websocketRpcRouteLayer,
344353 ),
345 attachmentsRouteLayer,
346 otlpTracesProxyRouteLayer,
347 projectFaviconRouteLayer,
348 staticAndDevRouteLayer,
349 websocketRpcRouteLayer,
354 McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)),
350355).pipe(Layer.provide(browserApiCorsLayer));
351356
352357export const makeServerLayer = Layer.unwrap(
apps/server/src/ws.ts+29
......@@ -73,6 +73,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe
7373import { TerminalManager } from "./terminal/Services/Manager.ts";
7474import { PreviewManager } from "./preview/Services/Manager.ts";
7575import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
76import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts";
7677import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
7778import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
7879import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";
......@@ -189,6 +190,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
189190 [WS_METHODS.previewClose, AuthOrchestrationOperateScope],
190191 [WS_METHODS.previewList, AuthOrchestrationReadScope],
191192 [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope],
193 [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope],
194 [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope],
195 [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope],
196 [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope],
192197 [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope],
193198 [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope],
194199 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
......@@ -1387,6 +1392,30 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
13871392 observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), {
13881393 "rpc.aggregate": "preview",
13891394 }),
1395 [WS_METHODS.previewAutomationConnect]: (input) =>
1396 observeRpcStreamEffect(
1397 WS_METHODS.previewAutomationConnect,
1398 previewAutomationBroker.connect(input.clientId),
1399 { "rpc.aggregate": "preview-automation" },
1400 ),
1401 [WS_METHODS.previewAutomationRespond]: (input) =>
1402 observeRpcEffect(
1403 WS_METHODS.previewAutomationRespond,
1404 previewAutomationBroker.respond(input),
1405 { "rpc.aggregate": "preview-automation" },
1406 ),
1407 [WS_METHODS.previewAutomationReportOwner]: (input) =>
1408 observeRpcEffect(
1409 WS_METHODS.previewAutomationReportOwner,
1410 previewAutomationBroker.reportOwner(input),
1411 { "rpc.aggregate": "preview-automation" },
1412 ),
1413 [WS_METHODS.previewAutomationClearOwner]: (input) =>
1414 observeRpcEffect(
1415 WS_METHODS.previewAutomationClearOwner,
1416 previewAutomationBroker.clearOwner(input.clientId),
1417 { "rpc.aggregate": "preview-automation" },
1418 ),
13901419 [WS_METHODS.subscribePreviewEvents]: (_input) =>
13911420 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
13921421 "rpc.aggregate": "preview",
apps/web/src/components/ChatView.browser.tsx+6
......@@ -285,6 +285,12 @@ function createMockEnvironmentApi(input: {
285285 reportStatus: () => {
286286 throw new Error("Not implemented in browser test.");
287287 },
288 automation: {
289 connect: () => () => undefined,
290 respond: () => Promise.resolve(),
291 reportOwner: () => Promise.resolve(),
292 clearOwner: () => Promise.resolve(),
293 },
288294 onEvent: () => () => undefined,
289295 subscribePorts: () => () => undefined,
290296 } as EnvironmentApi["preview"],
apps/web/src/components/ChatView.logic.test.ts+46
......@@ -12,6 +12,7 @@ import { type EnvironmentState, useStore } from "../store";
1212import { type Thread } from "../types";
1313
1414import {
15 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1516 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
1617 buildExpiredTerminalContextToastCopy,
1718 createLocalDispatchSnapshot,
......@@ -19,6 +20,7 @@ import {
1920 getStartedThreadModelChangeBlockReason,
2021 hasServerAcknowledgedLocalDispatch,
2122 reconcileMountedTerminalThreadIds,
23 reconcileRetainedMountedThreadIds,
2224 resolveSendEnvMode,
2325 shouldWriteThreadErrorToCurrentServerThread,
2426 waitForStartedServerThread,
......@@ -274,6 +276,50 @@ describe("reconcileMountedTerminalThreadIds", () => {
274276 });
275277});
276278
279describe("reconcileRetainedMountedThreadIds", () => {
280 it("retains hidden open threads and adds the active open thread", () => {
281 expect(
282 reconcileRetainedMountedThreadIds({
283 currentThreadIds: [ThreadId.make("thread-hidden")],
284 openThreadIds: [ThreadId.make("thread-hidden")],
285 activeThreadId: ThreadId.make("thread-active"),
286 activeThreadOpen: true,
287 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
288 }),
289 ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]);
290 });
291
292 it("can retain the active thread as hidden when it is inactive", () => {
293 expect(
294 reconcileRetainedMountedThreadIds({
295 currentThreadIds: [ThreadId.make("thread-active")],
296 openThreadIds: [ThreadId.make("thread-active")],
297 activeThreadId: ThreadId.make("thread-active"),
298 activeThreadOpen: false,
299 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
300 retainInactiveActiveThread: true,
301 }),
302 ).toEqual([ThreadId.make("thread-active")]);
303 });
304
305 it("evicts the oldest hidden threads beyond the configured cap", () => {
306 const currentThreadIds = Array.from(
307 { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 },
308 (_, index) => ThreadId.make(`thread-${index + 1}`),
309 );
310
311 expect(
312 reconcileRetainedMountedThreadIds({
313 currentThreadIds,
314 openThreadIds: currentThreadIds,
315 activeThreadId: null,
316 activeThreadOpen: false,
317 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
318 }),
319 ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS));
320 });
321});
322
277323describe("shouldWriteThreadErrorToCurrentServerThread", () => {
278324 it("routes errors to the active server thread when route and target match", () => {
279325 const threadId = ThreadId.make("thread-1");
apps/web/src/components/ChatView.logic.ts+23-6
......@@ -22,6 +22,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore";
2222
2323export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2424export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
25export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
2526
2627export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2728
......@@ -79,15 +80,31 @@ export function reconcileMountedTerminalThreadIds(input: {
7980 activeThreadId: string | null;
8081 activeThreadTerminalOpen: boolean;
8182 maxHiddenThreadCount?: number;
83}): string[] {
84 return reconcileRetainedMountedThreadIds({
85 currentThreadIds: input.currentThreadIds,
86 openThreadIds: input.openThreadIds,
87 activeThreadId: input.activeThreadId,
88 activeThreadOpen: input.activeThreadTerminalOpen,
89 maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
90 });
91}
92
93export function reconcileRetainedMountedThreadIds(input: {
94 currentThreadIds: ReadonlyArray<string>;
95 openThreadIds: ReadonlyArray<string>;
96 activeThreadId: string | null;
97 activeThreadOpen: boolean;
98 maxHiddenThreadCount: number;
99 retainInactiveActiveThread?: boolean;
82100}): string[] {
83101 const openThreadIdSet = new Set(input.openThreadIds);
84102 const hiddenThreadIds = input.currentThreadIds.filter(
85 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
86 );
87 const maxHiddenThreadCount = Math.max(
88 0,
89 input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
103 (threadId) =>
104 (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) &&
105 openThreadIdSet.has(threadId),
90106 );
107 const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount);
91108 const nextThreadIds =
92109 hiddenThreadIds.length > maxHiddenThreadCount
93110 ? hiddenThreadIds.slice(-maxHiddenThreadCount)
......@@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: {
95112
96113 if (
97114 input.activeThreadId &&
98 input.activeThreadTerminalOpen &&
115 input.activeThreadOpen &&
99116 !nextThreadIds.includes(input.activeThreadId)
100117 ) {
101118 nextThreadIds.push(input.activeThreadId);
apps/web/src/components/ChatView.tsx+73-6
......@@ -99,7 +99,8 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
9999import { useMediaQuery } from "../hooks/useMediaQuery";
100100import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
101101import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore";
102import { isPreviewSupportedInRuntime } from "../previewStateStore";
102import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
103import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore";
103104import { subscribePreviewAction } from "./preview/previewActionBus";
104105// Lazy: keeps the entire preview component graph (webview host, favicon
105106// helper, Chromium error icon) out of the web bundle until first open.
......@@ -166,6 +167,7 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
166167import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
167168import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
168169import {
170 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
169171 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
170172 buildExpiredTerminalContextToastCopy,
171173 buildLocalDraftThread,
......@@ -182,6 +184,7 @@ import {
182184 deriveLockedProvider,
183185 readFileAsDataUrl,
184186 reconcileMountedTerminalThreadIds,
187 reconcileRetainedMountedThreadIds,
185188 resolveSendEnvMode,
186189 revokeBlobPreviewUrl,
187190 revokeUserMessagePreviewUrls,
......@@ -996,6 +999,22 @@ export default function ChatView(props: ChatViewProps) {
996999 }),
9971000 [mountedTerminalThreadKeys],
9981001 );
1002 const previewSessionThreadKeys = usePreviewStateStore(
1003 useShallow((state) =>
1004 Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) =>
1005 nextPreviewState.snapshot ? [nextThreadKey] : [],
1006 ),
1007 ),
1008 );
1009 const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState<string[]>([]);
1010 const mountedPreviewThreadRefs = useMemo(
1011 () =>
1012 mountedPreviewThreadKeys.flatMap((mountedThreadKey) => {
1013 const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
1014 return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : [];
1015 }),
1016 [mountedPreviewThreadKeys],
1017 );
9991018
10001019 const fallbackDraftProjectRef = draftThread
10011020 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
......@@ -1092,6 +1111,12 @@ export default function ChatView(props: ChatViewProps) {
10921111 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
10931112 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
10941113 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1114 const existingPreviewSessionThreadKeys = useMemo(() => {
1115 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1116 return previewSessionThreadKeys.filter((nextThreadKey) =>
1117 existingThreadKeys.has(nextThreadKey),
1118 );
1119 }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]);
10951120 const activeLatestTurn = activeThread?.latestTurn ?? null;
10961121 const threadPlanCatalog = useThreadPlanCatalog(
10971122 useMemo(() => {
......@@ -1121,6 +1146,27 @@ export default function ChatView(props: ChatViewProps) {
11211146 : nextThreadIds;
11221147 });
11231148 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
1149 useEffect(() => {
1150 setMountedPreviewThreadKeys((currentThreadIds) => {
1151 const nextThreadIds = reconcileRetainedMountedThreadIds({
1152 currentThreadIds,
1153 openThreadIds: existingPreviewSessionThreadKeys,
1154 activeThreadId: activeThreadKey,
1155 activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet),
1156 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1157 retainInactiveActiveThread: true,
1158 });
1159 return currentThreadIds.length === nextThreadIds.length &&
1160 currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index])
1161 ? currentThreadIds
1162 : nextThreadIds;
1163 });
1164 }, [
1165 activeThreadKey,
1166 existingPreviewSessionThreadKeys,
1167 previewPanelOpen,
1168 shouldUsePlanSidebarSheet,
1169 ]);
11241170 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
11251171 const activeProjectRef = activeThread
11261172 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
......@@ -4148,14 +4194,35 @@ export default function ChatView(props: ChatViewProps) {
41484194 onClose={closePlanSidebar}
41494195 />
41504196 ) : null}
4151 {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? (
4152 <Suspense fallback={null}>
4153 <PreviewPanel mode="inline" threadRef={activeThreadRef} visible />
4154 </Suspense>
4155 ) : null}
4197 {!shouldUsePlanSidebarSheet
4198 ? mountedPreviewThreadRefs.map(
4199 ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => {
4200 const visible = previewPanelOpen && mountedThreadKey === activeThreadKey;
4201 return (
4202 <div
4203 key={mountedThreadKey}
4204 className={cn(
4205 visible
4206 ? "contents"
4207 : "pointer-events-none fixed -left-[10000px] top-0 h-px w-px overflow-hidden opacity-0",
4208 )}
4209 aria-hidden={visible ? undefined : true}
4210 >
4211 <Suspense fallback={null}>
4212 <PreviewPanel mode="inline" threadRef={mountedThreadRef} visible={visible} />
4213 </Suspense>
4214 </div>
4215 );
4216 },
4217 )
4218 : null}
41564219 </div>
41574220 {/* end horizontal flex container */}
41584221
4222 {activeThreadRef ? (
4223 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
4224 ) : null}
4225
41594226 {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
41604227 <PersistentThreadTerminalDrawer
41614228 key={mountedThreadKey}
apps/web/src/components/preview/PreviewAutomationOwner.tsx created+287
......@@ -0,0 +1,287 @@
1"use client";
2// @effect-diagnostics cryptoRandomUUID:off
3
4import { scopedThreadKey } from "@t3tools/client-runtime";
5import type {
6 PreviewAutomationNavigateInput,
7 PreviewAutomationOpenInput,
8 PreviewAutomationRequest,
9 PreviewAutomationResponse,
10 PreviewAutomationStatus,
11 ScopedThreadRef,
12} from "@t3tools/contracts";
13import { useCallback, useEffect, useRef } from "react";
14
15import { ensureEnvironmentApi } from "~/environmentApi";
16import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
17import { useRightPanelStore } from "~/rightPanelStore";
18
19import { previewBridge } from "./previewBridge";
20
21const automationClientId =
22 typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
23 ? crypto.randomUUID()
24 : `preview-${Math.random().toString(36).slice(2)}`;
25
26const waitForDesktopOverlay = async (
27 threadRef: ScopedThreadRef,
28 timeoutMs: number,
29): Promise<void> => {
30 const deadline = Date.now() + timeoutMs;
31 while (Date.now() <= deadline) {
32 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef);
33 const tabId = state.snapshot?.tabId;
34 if (tabId && state.desktopOverlay && previewBridge) {
35 const status = await previewBridge.automation.status(tabId);
36 if (status.available) return;
37 }
38 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
39 }
40 const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`);
41 error.name = "PreviewAutomationTimeoutError";
42 throw error;
43};
44
45const waitForNavigationReadiness = async (
46 tabId: string,
47 readiness: PreviewAutomationNavigateInput["readiness"],
48 timeoutMs: number,
49): Promise<void> => {
50 if (!previewBridge || readiness === "none") return;
51 const deadline = Date.now() + timeoutMs;
52 while (Date.now() <= deadline) {
53 if (readiness === "domContentLoaded") {
54 const readyState = await previewBridge.automation.evaluate(tabId, {
55 expression: "document.readyState",
56 });
57 if (readyState === "interactive" || readyState === "complete") return;
58 } else {
59 const status = await previewBridge.automation.status(tabId);
60 if (!status.loading) return;
61 }
62 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
63 }
64 const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`);
65 error.name = "PreviewAutomationTimeoutError";
66 throw error;
67};
68
69const currentStatus = async (
70 threadRef: ScopedThreadRef,
71 visible: boolean,
72): Promise<PreviewAutomationStatus> => {
73 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef);
74 const tabId = state.snapshot?.tabId ?? null;
75 if (tabId && previewBridge && state.desktopOverlay) {
76 const status = await previewBridge.automation.status(tabId);
77 return { ...status, visible };
78 }
79 const navStatus = state.snapshot?.navStatus;
80 return {
81 available: Boolean(previewBridge?.automation),
82 visible,
83 tabId,
84 url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
85 title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null,
86 loading: navStatus?._tag === "Loading",
87 };
88};
89
90const serializeError = (error: unknown): NonNullable<PreviewAutomationResponse["error"]> => {
91 if (error instanceof Error) {
92 const detail =
93 "detail" in error && (error as { detail?: unknown }).detail !== undefined
94 ? (error as { detail?: unknown }).detail
95 : undefined;
96 return {
97 _tag: error.name.startsWith("PreviewAutomation")
98 ? error.name
99 : "PreviewAutomationExecutionError",
100 message: error.message,
101 ...(detail === undefined ? {} : { detail }),
102 };
103 }
104 return {
105 _tag: "PreviewAutomationExecutionError",
106 message: String(error),
107 };
108};
109
110export function PreviewAutomationOwner(props: {
111 readonly threadRef: ScopedThreadRef;
112 readonly visible: boolean;
113}) {
114 const { threadRef, visible } = props;
115 const ownerStateRef = useRef({ threadRef, visible });
116 const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>(
117 async () => undefined,
118 );
119 ownerStateRef.current = { threadRef, visible };
120
121 const handleRequest = useCallback(
122 async (request: PreviewAutomationRequest): Promise<unknown> => {
123 if (request.threadId !== threadRef.threadId) {
124 const error = new Error("Preview automation request targeted a stale thread owner.");
125 error.name = "PreviewAutomationUnavailableError";
126 throw error;
127 }
128 const api = ensureEnvironmentApi(threadRef.environmentId);
129 const state = selectThreadPreviewState(
130 usePreviewStateStore.getState().byThreadKey,
131 threadRef,
132 );
133 const tabId = request.tabId ?? state.snapshot?.tabId ?? null;
134 switch (request.operation) {
135 case "status":
136 return currentStatus(threadRef, visible);
137 case "open": {
138 const input = request.input as PreviewAutomationOpenInput;
139 if (input.show ?? true) {
140 useRightPanelStore.getState().open(threadRef, "preview");
141 }
142 let activeTabId =
143 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
144 if (!activeTabId) {
145 const snapshot = await api.preview.open({
146 threadId: threadRef.threadId,
147 ...(input.url ? { url: input.url } : {}),
148 });
149 usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot);
150 activeTabId = snapshot.tabId;
151 } else if (input.url && previewBridge) {
152 await previewBridge.navigate(activeTabId, input.url);
153 }
154 await waitForDesktopOverlay(threadRef, request.timeoutMs);
155 return currentStatus(threadRef, input.show ?? true);
156 }
157 case "navigate": {
158 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
159 const input = request.input as PreviewAutomationNavigateInput;
160 await previewBridge.navigate(tabId, input.url);
161 await waitForNavigationReadiness(
162 tabId,
163 input.readiness ?? "load",
164 input.timeoutMs ?? request.timeoutMs,
165 );
166 return currentStatus(threadRef, visible);
167 }
168 case "snapshot":
169 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
170 return previewBridge.automation.snapshot(tabId);
171 case "click":
172 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
173 return previewBridge.automation.click(
174 tabId,
175 request.input as Parameters<typeof previewBridge.automation.click>[1],
176 );
177 case "type":
178 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
179 return previewBridge.automation.type(
180 tabId,
181 request.input as Parameters<typeof previewBridge.automation.type>[1],
182 );
183 case "press":
184 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
185 return previewBridge.automation.press(
186 tabId,
187 request.input as Parameters<typeof previewBridge.automation.press>[1],
188 );
189 case "scroll":
190 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
191 return previewBridge.automation.scroll(
192 tabId,
193 request.input as Parameters<typeof previewBridge.automation.scroll>[1],
194 );
195 case "evaluate":
196 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
197 return previewBridge.automation.evaluate(
198 tabId,
199 request.input as Parameters<typeof previewBridge.automation.evaluate>[1],
200 );
201 case "waitFor":
202 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
203 return previewBridge.automation.waitFor(
204 tabId,
205 request.input as Parameters<typeof previewBridge.automation.waitFor>[1],
206 );
207 }
208 },
209 [threadRef, visible],
210 );
211 handlerRef.current = handleRequest;
212
213 useEffect(() => {
214 const api = ensureEnvironmentApi(threadRef.environmentId);
215 return api.preview.automation.connect(
216 { clientId: automationClientId },
217 (request) => {
218 void handlerRef.current(request).then(
219 (result) =>
220 api.preview.automation.respond({
221 requestId: request.requestId,
222 ok: true,
223 ...(result === undefined ? {} : { result }),
224 }),
225 (error) =>
226 api.preview.automation.respond({
227 requestId: request.requestId,
228 ok: false,
229 error: serializeError(error),
230 }),
231 );
232 },
233 {
234 onResubscribe: () => {
235 const ownerState = ownerStateRef.current;
236 const state = selectThreadPreviewState(
237 usePreviewStateStore.getState().byThreadKey,
238 ownerState.threadRef,
239 );
240 void api.preview.automation.reportOwner({
241 clientId: automationClientId,
242 environmentId: ownerState.threadRef.environmentId,
243 threadId: ownerState.threadRef.threadId,
244 tabId: state.snapshot?.tabId ?? null,
245 visible: ownerState.visible,
246 supportsAutomation: Boolean(previewBridge?.automation),
247 focusedAt: new Date().toISOString(),
248 });
249 },
250 },
251 );
252 }, [threadRef.environmentId]);
253
254 useEffect(() => {
255 const api = ensureEnvironmentApi(threadRef.environmentId);
256 const report = () => {
257 const state = selectThreadPreviewState(
258 usePreviewStateStore.getState().byThreadKey,
259 threadRef,
260 );
261 void api.preview.automation.reportOwner({
262 clientId: automationClientId,
263 environmentId: threadRef.environmentId,
264 threadId: threadRef.threadId,
265 tabId: state.snapshot?.tabId ?? null,
266 visible,
267 supportsAutomation: Boolean(previewBridge?.automation),
268 focusedAt: new Date().toISOString(),
269 });
270 };
271 report();
272 window.addEventListener("focus", report);
273 const unsubscribe = usePreviewStateStore.subscribe((state, previous) => {
274 const key = scopedThreadKey(threadRef);
275 if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) {
276 report();
277 }
278 });
279 return () => {
280 window.removeEventListener("focus", report);
281 unsubscribe();
282 void api.preview.automation.clearOwner({ clientId: automationClientId });
283 };
284 }, [threadRef, visible]);
285
286 return null;
287}
apps/web/src/environmentApi.ts+7
......@@ -65,6 +65,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
6565 close: (input) => rpcClient.preview.close(input as never),
6666 list: (input) => rpcClient.preview.list(input as never),
6767 reportStatus: (input) => rpcClient.preview.reportStatus(input as never),
68 automation: {
69 connect: (input, callback, options) =>
70 rpcClient.preview.automation.connect(input as never, callback, options),
71 respond: (response) => rpcClient.preview.automation.respond(response as never),
72 reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never),
73 clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never),
74 },
6875 onEvent: (callback) => rpcClient.preview.onEvent(callback),
6976 subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options),
7077 },
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+6
......@@ -111,6 +111,12 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
111111 close: vi.fn(),
112112 list: vi.fn(),
113113 reportStatus: vi.fn(),
114 automation: {
115 connect: vi.fn(() => () => undefined),
116 respond: vi.fn(),
117 reportOwner: vi.fn(),
118 clearOwner: vi.fn(),
119 },
114120 onEvent: vi.fn(() => () => undefined),
115121 subscribePorts: vi.fn(() => () => undefined),
116122 },
packages/client-runtime/src/wsRpcClient.ts+20
......@@ -86,6 +86,12 @@ export interface WsRpcClient {
8686 readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>;
8787 readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>;
8888 readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>;
89 readonly automation: {
90 readonly connect: RpcInputStreamMethod<typeof WS_METHODS.previewAutomationConnect>;
91 readonly respond: RpcUnaryMethod<typeof WS_METHODS.previewAutomationRespond>;
92 readonly reportOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationReportOwner>;
93 readonly clearOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationClearOwner>;
94 };
8995 readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>;
9096 readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>;
9197 };
......@@ -230,6 +236,20 @@ export function createWsRpcClient(
230236 list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)),
231237 reportStatus: (input) =>
232238 transport.request((client) => client[WS_METHODS.previewReportStatus](input)),
239 automation: {
240 connect: (input, listener, options) =>
241 transport.subscribe(
242 (client) => client[WS_METHODS.previewAutomationConnect](input),
243 listener,
244 subscriptionOptions(options, WS_METHODS.previewAutomationConnect),
245 ),
246 respond: (input) =>
247 transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)),
248 reportOwner: (input) =>
249 transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)),
250 clearOwner: (input) =>
251 transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)),
252 },
233253 onEvent: (listener, options) =>
234254 transport.subscribe(
235255 (client) => client[WS_METHODS.subscribePreviewEvents]({}),
packages/contracts/src/index.ts+1
......@@ -23,4 +23,5 @@ export * from "./project.ts";
2323export * from "./filesystem.ts";
2424export * from "./review.ts";
2525export * from "./preview.ts";
26export * from "./previewAutomation.ts";
2627export * from "./rpc.ts";
packages/contracts/src/ipc.ts+33
......@@ -66,6 +66,19 @@ import type {
6666 PreviewReportStatusInput,
6767 PreviewSessionSnapshot,
6868} from "./preview.ts";
69import type {
70 PreviewAutomationClickInput,
71 PreviewAutomationEvaluateInput,
72 PreviewAutomationOwner,
73 PreviewAutomationPressInput,
74 PreviewAutomationRequest,
75 PreviewAutomationResponse,
76 PreviewAutomationScrollInput,
77 PreviewAutomationSnapshot,
78 PreviewAutomationStatus,
79 PreviewAutomationTypeInput,
80 PreviewAutomationWaitForInput,
81} from "./previewAutomation.ts";
6982import type {
7083 ClientOrchestrationCommand,
7184 OrchestrationGetFullThreadDiffInput,
......@@ -668,6 +681,16 @@ export interface DesktopPreviewBridge {
668681 pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>;
669682 /** Cancel an in-flight preview annotation session. */
670683 cancelPickElement: (tabId: string) => Promise<void>;
684 automation: {
685 status: (tabId: string) => Promise<PreviewAutomationStatus>;
686 snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>;
687 click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>;
688 type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>;
689 press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>;
690 scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>;
691 evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>;
692 waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>;
693 };
671694 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
672695}
673696
......@@ -835,6 +858,16 @@ export interface EnvironmentApi {
835858 close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>;
836859 list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>;
837860 reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>;
861 automation: {
862 connect: (
863 input: { clientId: string },
864 callback: (request: PreviewAutomationRequest) => void,
865 options?: { onResubscribe?: () => void },
866 ) => () => void;
867 respond: (response: PreviewAutomationResponse) => Promise<void>;
868 reportOwner: (owner: PreviewAutomationOwner) => Promise<void>;
869 clearOwner: (input: { clientId: string }) => Promise<void>;
870 };
838871 onEvent: (callback: (event: PreviewEvent) => void) => () => void;
839872 subscribePorts: (
840873 callback: (servers: DiscoveredLocalServerList) => void,
packages/contracts/src/previewAutomation.ts created+219
......@@ -0,0 +1,219 @@
1import { Schema } from "effect";
2
3import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { PreviewTabId } from "./preview.ts";
5
6const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048));
7const OptionalTimeoutMs = Schema.optional(
8 Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)),
9);
10
11export const PreviewAutomationOperation = Schema.Literals([
12 "status",
13 "open",
14 "navigate",
15 "snapshot",
16 "click",
17 "type",
18 "press",
19 "scroll",
20 "evaluate",
21 "waitFor",
22]);
23export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
24
25export const PreviewAutomationStatus = Schema.Struct({
26 available: Schema.Boolean,
27 visible: Schema.Boolean,
28 tabId: Schema.NullOr(PreviewTabId),
29 url: Schema.NullOr(Schema.String),
30 title: Schema.NullOr(Schema.String),
31 loading: Schema.Boolean,
32});
33export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
34
35export const PreviewAutomationOpenInput = Schema.Struct({
36 url: Schema.optional(BoundedUrl),
37 show: Schema.optional(Schema.Boolean),
38 reuseExistingTab: Schema.optional(Schema.Boolean),
39});
40export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type;
41
42export const PreviewAutomationNavigateInput = Schema.Struct({
43 url: BoundedUrl,
44 readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])),
45 timeoutMs: OptionalTimeoutMs,
46});
47export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
48
49export const PreviewAutomationClickInput = Schema.Union([
50 Schema.Struct({
51 selector: TrimmedNonEmptyString,
52 timeoutMs: OptionalTimeoutMs,
53 }),
54 Schema.Struct({
55 x: Schema.Number,
56 y: Schema.Number,
57 timeoutMs: OptionalTimeoutMs,
58 }),
59]);
60export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;
61
62export const PreviewAutomationTypeInput = Schema.Struct({
63 text: Schema.String,
64 selector: Schema.optional(TrimmedNonEmptyString),
65 clear: Schema.optional(Schema.Boolean),
66 timeoutMs: OptionalTimeoutMs,
67});
68export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type;
69
70export const PreviewAutomationPressInput = Schema.Struct({
71 key: TrimmedNonEmptyString,
72 modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))),
73});
74export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type;
75
76export const PreviewAutomationScrollInput = Schema.Struct({
77 deltaX: Schema.optional(Schema.Number),
78 deltaY: Schema.optional(Schema.Number),
79 selector: Schema.optional(TrimmedNonEmptyString),
80});
81export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;
82
83export const PreviewAutomationEvaluateInput = Schema.Struct({
84 expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)),
85 awaitPromise: Schema.optional(Schema.Boolean),
86 returnByValue: Schema.optional(Schema.Boolean),
87});
88export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type;
89
90export const PreviewAutomationWaitForInput = Schema.Struct({
91 selector: Schema.optional(TrimmedNonEmptyString),
92 text: Schema.optional(TrimmedNonEmptyString),
93 urlIncludes: Schema.optional(TrimmedNonEmptyString),
94 timeoutMs: OptionalTimeoutMs,
95});
96export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type;
97
98export const PreviewAutomationElement = Schema.Struct({
99 tag: Schema.String,
100 role: Schema.NullOr(Schema.String),
101 name: Schema.String,
102 selector: Schema.String,
103 x: Schema.Number,
104 y: Schema.Number,
105 width: Schema.Number,
106 height: Schema.Number,
107});
108export type PreviewAutomationElement = typeof PreviewAutomationElement.Type;
109
110export const PreviewAutomationSnapshot = Schema.Struct({
111 url: Schema.String,
112 title: Schema.String,
113 loading: Schema.Boolean,
114 visibleText: Schema.String,
115 interactiveElements: Schema.Array(PreviewAutomationElement),
116 accessibilityTree: Schema.Unknown,
117 screenshot: Schema.Struct({
118 mimeType: Schema.Literal("image/png"),
119 data: Schema.String,
120 width: Schema.Int,
121 height: Schema.Int,
122 }),
123});
124export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type;
125
126export const PreviewAutomationOwner = Schema.Struct({
127 clientId: TrimmedNonEmptyString,
128 environmentId: EnvironmentId,
129 threadId: ThreadId,
130 tabId: Schema.NullOr(PreviewTabId),
131 visible: Schema.Boolean,
132 supportsAutomation: Schema.Boolean,
133 focusedAt: Schema.String,
134});
135export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type;
136
137export const PreviewAutomationRequest = Schema.Struct({
138 requestId: TrimmedNonEmptyString,
139 threadId: ThreadId,
140 tabId: Schema.optional(PreviewTabId),
141 operation: PreviewAutomationOperation,
142 input: Schema.Unknown,
143 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
144});
145export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type;
146
147export const PreviewAutomationResponse = Schema.Struct({
148 requestId: TrimmedNonEmptyString,
149 ok: Schema.Boolean,
150 result: Schema.optional(Schema.Unknown),
151 error: Schema.optional(
152 Schema.Struct({
153 _tag: TrimmedNonEmptyString,
154 message: Schema.String,
155 detail: Schema.optional(Schema.Unknown),
156 }),
157 ),
158});
159export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;
160
161export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationUnavailableError>()(
162 "PreviewAutomationUnavailableError",
163 { message: Schema.String },
164) {}
165
166export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()(
167 "PreviewAutomationNoFocusedOwnerError",
168 { message: Schema.String },
169) {}
170
171export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()(
172 "PreviewAutomationUnsupportedClientError",
173 { message: Schema.String },
174) {}
175
176export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()(
177 "PreviewAutomationTabNotFoundError",
178 { message: Schema.String },
179) {}
180
181export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()(
182 "PreviewAutomationTimeoutError",
183 { message: Schema.String },
184) {}
185
186export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()(
187 "PreviewAutomationExecutionError",
188 { message: Schema.String, detail: Schema.optional(Schema.Unknown) },
189) {}
190
191export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()(
192 "PreviewAutomationInvalidSelectorError",
193 { message: Schema.String, selector: Schema.String },
194) {}
195
196export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()(
197 "PreviewAutomationResultTooLargeError",
198 { message: Schema.String, maximumBytes: Schema.Int },
199) {}
200
201export const PreviewAutomationError = Schema.Union([
202 PreviewAutomationUnavailableError,
203 PreviewAutomationNoFocusedOwnerError,
204 PreviewAutomationUnsupportedClientError,
205 PreviewAutomationTabNotFoundError,
206 PreviewAutomationTimeoutError,
207 PreviewAutomationExecutionError,
208 PreviewAutomationInvalidSelectorError,
209 PreviewAutomationResultTooLargeError,
210]);
211export type PreviewAutomationError = typeof PreviewAutomationError.Type;
212
213export const PreviewUrlResolution = Schema.Struct({
214 requestedUrl: Schema.String,
215 resolvedUrl: Schema.String,
216 resolutionKind: Schema.Literal("direct"),
217 environmentId: EnvironmentId,
218});
219export type PreviewUrlResolution = typeof PreviewUrlResolution.Type;
packages/contracts/src/rpc.ts+36
......@@ -98,6 +98,12 @@ import {
9898 PreviewReportStatusInput,
9999 PreviewSessionSnapshot,
100100} from "./preview.ts";
101import {
102 PreviewAutomationError,
103 PreviewAutomationOwner,
104 PreviewAutomationRequest,
105 PreviewAutomationResponse,
106} from "./previewAutomation.ts";
101107import {
102108 ServerConfigStreamEvent,
103109 ServerConfig,
......@@ -177,6 +183,10 @@ export const WS_METHODS = {
177183 previewClose: "preview.close",
178184 previewList: "preview.list",
179185 previewReportStatus: "preview.reportStatus",
186 previewAutomationConnect: "previewAutomation.connect",
187 previewAutomationRespond: "previewAutomation.respond",
188 previewAutomationReportOwner: "previewAutomation.reportOwner",
189 previewAutomationClearOwner: "previewAutomation.clearOwner",
180190
181191 // Server meta
182192 serverGetConfig: "server.getConfig",
......@@ -510,6 +520,28 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,
510520 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
511521});
512522
523export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
524 payload: Schema.Struct({ clientId: Schema.String }),
525 success: PreviewAutomationRequest,
526 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
527 stream: true,
528});
529
530export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, {
531 payload: PreviewAutomationResponse,
532 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
533});
534
535export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, {
536 payload: PreviewAutomationOwner,
537 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
538});
539
540export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, {
541 payload: Schema.Struct({ clientId: Schema.String }),
542 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
543});
544
513545export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
514546 payload: Schema.Struct({}),
515547 success: PreviewEvent,
......@@ -668,6 +700,10 @@ export const WsRpcGroup = RpcGroup.make(
668700 WsPreviewCloseRpc,
669701 WsPreviewListRpc,
670702 WsPreviewReportStatusRpc,
703 WsPreviewAutomationConnectRpc,
704 WsPreviewAutomationRespondRpc,
705 WsPreviewAutomationReportOwnerRpc,
706 WsPreviewAutomationClearOwnerRpc,
671707 WsSubscribePreviewEventsRpc,
672708 WsSubscribeDiscoveredLocalServersRpc,
673709 WsSubscribeServerConfigRpc,
vite.config.ts+6
......@@ -1,7 +1,13 @@
11import "vite-plus/test/config";
22import { defineConfig } from "vite-plus";
3import { fileURLToPath } from "node:url";
34
45export default defineConfig({
6 resolve: {
7 alias: {
8 "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)),
9 },
10 },
511 test: {
612 environment: "node",
713 exclude: [