@fluentui-react-native/desktop-driver-mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ ---
2
+ id: desktop-driver-mcp.current-status
3
+ kind: instruction-reference
4
+ owner_paths:
5
+ - packages/agentic/desktop-driver-mcp
6
+ ---
7
+
8
+ # Current adapter and measurement status
9
+
10
+ **Work in progress, updated 2026-11-05.** The adapter is a thin client, not a
11
+ supervisor. Keep this package name until actual harness-owned management tools
12
+ land; a later `desktop-mcp` facade must retain driver-only use and plan published
13
+ compatibility. Done below describes implementation, not full native qualification.
14
+ Follow-up work is tracked in
15
+ [desktop driver improvements (#4435)](https://github.com/microsoft/fluentui-react-native/issues/4435);
16
+ connection isolation is [#4440](https://github.com/microsoft/fluentui-react-native/issues/4440)
17
+ and the generalized management facet is
18
+ [#4450](https://github.com/microsoft/fluentui-react-native/issues/4450).
19
+
20
+ ## Capability matrix
21
+
22
+ | Capability | State | Boundary/remaining work |
23
+ | ------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | Six tools over shared driver schemas | Done | Session, observe, act, check, capture, story. No separate execution path. |
25
+ | Private descriptor and native identity verification | Done | Verifies before sending a grant. No model URL/token/config/executable input. |
26
+ | Single-flight connection/session lifetime | Done | Serial acquisition, close, operations and resource reads; late acquisition after dispose is cleaned up. |
27
+ | Same-port protected restart/revocation | Done in deterministic contracts | Invalid authority is forgotten; next call re-verifies discovery. No mutation replay. Live endpoint reruns remain required. |
28
+ | Same-authority transport retry | Done, bounded semantics | Retain known session cleanup responsibility and rebind only after verification of the same incarnation/principal. This is not cross-MCP-connection resume. |
29
+ | Lost create response | Fenced; recovery in progress | `session-uncertain`; no repeated acquisition. Operator must revoke the old grant or replace the service. No session-enumeration shortcut. |
30
+ | Failed delete/disconnect | Done with limits | Keep known-session responsibility; bounded dispose wait leaves late cleanup attached. Unreachable-service cleanup cannot be guaranteed; service drain/expiry remains necessary. |
31
+ | Launch-mode discovery | Done | Status lists effective launch/attach modes; no predictable attach-first failure against launch-only targets. |
32
+ | Progress, cancellation, paged image resources | Done | Progress does not renew idle sessions; artifacts are session-bound and hash-checked. |
33
+ | Storybook control | Experimental | Driver selection/marker path exists; channel ingress/reconnect and full exclusive context authority remain incomplete. |
34
+ | Per-connection attenuated grants and resumable sessions | Work in progress | Current supervisor publishes one shared agent grant. Labels are not client isolation. |
35
+ | Workload/test orchestration tools | Work in progress | Belong to a future harness control/client surface; adapter never becomes the resource owner. |
36
+ | General MCP-client qualification | Work in progress | SDK stdio evidence exists; client-specific resource/image/cancellation behavior needs independent runs. |
37
+
38
+ See [connection](connection.md) and [tools](tools.md) for exact behavior.
39
+ Native and Storybook limitations are tracked in
40
+ `@fluentui-react-native/desktop-driver/references/current-status.md`;
41
+ supervision gaps in
42
+ `@fluentui-react-native/desktop-test/references/current-status.md`.
43
+
44
+ ## Measured discovery and interaction costs
45
+
46
+ Historical measurements migrated from the root capability report, **not rerun**
47
+ by this update. `o200k_base` was used over all MCP text blocks; tokenizer package
48
+ version/raw portable artifact locator were not retained.
49
+
50
+ | Date / revision | Measurement | Result | Boundary |
51
+ | ------------------------ | -------------------------------------- | ------------------------------- | ----------------------------------------------------- |
52
+ | 2026-11-04 / `41fc7473f` | Six-tool `tools/list` | 4,688 text tokens; 20,269 bytes | Discovery once per connection; not per operation |
53
+ | Same | Server instructions | 120 text tokens | Additional discovery context |
54
+ | Same | Named Toggle + check + closing observe | 386 text tokens; 48 ms | One warm macOS Fabric run, not a latency distribution |
55
+ | Same | Physical click + checked check | 181 ms | One run |
56
+ | Same | Observe default | 29-31 ms median, max 35 ms | 20 warm calls per button/checkbox/menu story |
57
+ | Same | Delta | 32-35 ms median, max 37 ms | Same 20-call-per-story method |
58
+
59
+ The app was macOS Fabric Storybook at 2720 x 1988 pixels on a 2x display,
60
+ served through `storybook driver --macos --agent`, driven by an MCP SDK stdio
61
+ client. Operation text/scoping comparisons and their completeness are retained
62
+ in the driver's status reference rather than duplicated here.
63
+
64
+ The schema test limits serialized JavaScript string length to 22,000
65
+ **characters**. It is not a token or UTF-8 byte limit. The earlier proposed
66
+ 8 KiB discovery budget was not achieved; measured discovery is about 20.3 KB.
67
+ Changed descriptions/schema text invalidate an exact present-day 4,688-token
68
+ claim: retain that number only with its historical revision.
69
+
70
+ For each new run append revision, SDK/client version, platform/renderer,
71
+ tokenizer/version, serialized UTF-8 bytes and characters, text tokens,
72
+ instructions overhead, projection/budgets, sample count, cold/warm timing,
73
+ success/retry/cancellation/cleanup counts, and approved artifact locator.
74
+ Do not claim token reductions from reducing information scope or omitting
75
+ discovery. Windows and Paper measurements are currently **not recorded here**.
76
+
77
+ ## Before full submission
78
+
79
+ Re-run same-port replacement, revocation, dispose during open, failed delete,
80
+ ambiguous create, and resource invalidation through the real descriptor path on
81
+ macOS and Windows. Qualify Windows Fabric and Win32 Paper independently, with
82
+ physical Ctrl+C and interactive input/capture cases where supported. Current
83
+ fake-host tests validate portable controller semantics, not native ownership,
84
+ signing, TCC, Windows ACLs, DPI, or process containment. This requalification is
85
+ tracked in [#4441](https://github.com/microsoft/fluentui-react-native/issues/4441).
@@ -0,0 +1,134 @@
1
+ ---
2
+ id: desktop-driver-mcp.tools
3
+ kind: instruction-reference
4
+ owner_paths:
5
+ - packages/agentic/desktop-driver-mcp
6
+ ---
7
+
8
+ # Tools
9
+
10
+ Six tools group the driver's agent operations. Each operation's input schema,
11
+ result, and failure reasons are defined once in `@fluentui-react-native/desktop-driver`
12
+ (its agent operations reference); the tools reuse those schemas, so a field the
13
+ service rejects is rejected here first, before anything reaches the service.
14
+ Unknown fields are refused. The historical tool list measured about 20.3 KB;
15
+ the test bounds serialized JavaScript **characters** at 22,000, not tokens or
16
+ UTF-8 bytes. See [measurement history](current-status.md).
17
+
18
+ | Tool | Calls | Hints |
19
+ | ----------------- | ------------------------------------------------------------------- | --------------------------- |
20
+ | `desktop_session` | service status, session creation and deletion | not read-only |
21
+ | `desktop_observe` | `observe` (default), `delta`, `read`, `find`, `journal`, `artifact` | read-only |
22
+ | `desktop_act` | `sequence` | destructive, not idempotent |
23
+ | `desktop_check` | `check` | read-only |
24
+ | `desktop_capture` | `capture` | read-only |
25
+ | `desktop_story` | `stories` (default), `story` | destructive, not idempotent |
26
+
27
+ ## desktop_session
28
+
29
+ `action` is `status` (the default), `open`, or `close`.
30
+
31
+ - `status` lists the targets the grant allows, with their endpoint, platform,
32
+ renderer, effective `launchModes`, and whether a target is quarantined, plus
33
+ the open session. `sessionState: "uncertain"` reports an acquisition whose
34
+ response was lost.
35
+ - `open` needs a `target`. It attaches to the running application unless
36
+ `launch` is true, and accepts a `clickMode` of `accessibility`, `auto`, or
37
+ `physical`. It returns the session id, the click mode in effect, and which
38
+ operations the target supports, with the reason for any that are unavailable.
39
+ One session is open at a time; opening a second fails with `session-open`.
40
+ - `close` confirms deletion before forgetting the known session. Closing the MCP
41
+ connection stops admissions and attempts bounded cleanup, including late opens.
42
+
43
+ ## desktop_observe
44
+
45
+ Give at most one of `observe`, `delta`, `read`, `find`, `journal`, or
46
+ `artifact`, each with that operation's input; an empty call observes the
47
+ current window. `observe` returns an opaque `next` cursor when more of the same
48
+ sample is available. `delta` takes a `since` sample id and compares a fresh
49
+ observation with that retained base. `read` accepts the cursor from either
50
+ result and reads its next page without sampling again. The `artifact` form
51
+ pages through a capture for clients that cannot read resources.
52
+
53
+ ## desktop_act
54
+
55
+ Takes the `sequence` input: up to eight `act` and `check` steps that run in
56
+ order and stop at the first that fails or does not pass, with an optional
57
+ closing `observe`. Steps that ran stay done. Name a `requestId` (1 to 64
58
+ letters, digits, or `. _ : -`) to make a retry safe: repeating it returns the
59
+ first outcome instead of acting again while that outcome is retained in the
60
+ same session. This is not a retry guarantee across session/service replacement.
61
+
62
+ ## desktop_check
63
+
64
+ Takes the `check` input. A `waitMs` waits for the predicates to hold.
65
+
66
+ ## desktop_capture
67
+
68
+ Takes the `capture` input. Every capture returns a `resource_link` to
69
+ `desktop-driver://sessions/{session}/artifacts/{artifact}`. With `image: true`,
70
+ a capture under 1 MiB also returns an MCP image block; a larger one reports
71
+ `imageOmitted: "too-large"` and is read through the link.
72
+
73
+ Reading the link returns the whole PNG as one blob. The adapter pages it from
74
+ the service and checks the bytes against the recorded SHA-256. A link from a
75
+ session that is no longer open is refused, because artifact ids restart in
76
+ every session.
77
+
78
+ ## desktop_story
79
+
80
+ Give at most one of `list`, with the `stories` input, or `story`, with the
81
+ `story` input; an empty call lists the first page. `list` pages through the
82
+ build's story catalog and names the story this session rendered. `story`
83
+ renders a story by id (`action: "select"`, the default) or renders it again
84
+ from its initial state (`action: "reset"`); either expires every earlier
85
+ reference into the preview. `requestId` makes a `story` retry safe, as it does
86
+ for `desktop_act`.
87
+
88
+ The tool is always listed. On a target without Storybook, both forms fail with
89
+ `stories-unsupported`. When `list` reports `changed: true`, another client
90
+ rendered a story since this session's, and earlier references have expired.
91
+ Unknown or partial story effects quarantine mutations; do not retry with a new
92
+ request id to bypass that fence. The tool does not expose args. Protected driver
93
+ args updates are unsupported; alternate channel ingress is not yet fully governed.
94
+
95
+ ## Results
96
+
97
+ A successful call returns the operation's JSON envelope as one text block:
98
+ `schemaVersion`, `requestId`, `operation`, the `session` incarnation, and the
99
+ `result`. An observation outline is moved into its own text block, so its
100
+ lines stay readable, and the envelope's `outline` field names that block, as in
101
+ `"(text block 2)"`. Results carry no `structuredContent`, so nothing is sent
102
+ twice.
103
+
104
+ ## Errors
105
+
106
+ A tool failure is an `isError` result with one JSON text block:
107
+
108
+ | Field | Meaning |
109
+ | ----------- | --------------------------------------------------------------------------------------------------------- |
110
+ | `error` | The WebDriver error code, or `adapter` for a failure the adapter itself reports. |
111
+ | `reason` | The machine reason to branch on. |
112
+ | `message` | A bounded message. |
113
+ | `requestId` | The request id, when the service assigned or echoed one. |
114
+ | `mutation` | For an action, whether it was applied, rejected, or indeterminate, and whether it quarantined the target. |
115
+
116
+ The adapter's own reasons are `no-session`, `session-open`,
117
+ `session-uncertain`, `connection-closed`, `service-unavailable`, `cancelled`,
118
+ and `internal`. A malformed call is
119
+ refused with the schema's validation message.
120
+
121
+ A failed resource read is a JSON-RPC error: `-32602` with a `data.reason` of
122
+ `artifact-expired` or `artifact-changed`, or the service's reason for an
123
+ invalid argument; `-32603` otherwise.
124
+
125
+ ## Progress and cancellation
126
+
127
+ When a call carries a progress token, the adapter reports progress about once
128
+ a second until the call ends, so a client that resets its timeout on progress
129
+ can wait out a long check. The ticks never reach the service, so a waiting
130
+ model does not keep an idle session alive.
131
+
132
+ Cancelling a call aborts its HTTP request. The service treats that as the
133
+ caller leaving: work not yet dispatched is cancelled and journaled with the
134
+ reason `cancelled`, and the session stays usable.
package/src/cli.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import { Command } from 'commander';
3
+
4
+ import { connectWithDescriptor } from './connection.js';
5
+ import { createDesktopDriverMcpServer } from './server.js';
6
+
7
+ export type DesktopDriverMcpCliOptions = {
8
+ argv?: readonly string[];
9
+ /** The package version, reported by `--version` and at MCP initialization. */
10
+ version: string;
11
+ };
12
+
13
+ /**
14
+ * Serves MCP over stdio until the client closes stdin. stdout carries protocol only; diagnostics go to stderr.
15
+ */
16
+ export async function runDesktopDriverMcpCli({ argv = process.argv, version }: DesktopDriverMcpCliOptions): Promise<void> {
17
+ const program = new Command()
18
+ .name('desktop-driver-mcp')
19
+ .description('Serve Desktop Driver agent operations to an MCP client over stdio.')
20
+ .option('--descriptor <name>', 'record name of the connection descriptor a supervisor published', 'agent-connection.json')
21
+ .option('--root <path>', 'private storage root the descriptor was published under; defaults to the platform location')
22
+ .version(version)
23
+ .configureOutput({ writeOut: (value) => process.stderr.write(value) });
24
+ program.parse(argv);
25
+ const flags = program.opts<{ descriptor: string; root?: string }>();
26
+ const server = createDesktopDriverMcpServer({
27
+ connect: connectWithDescriptor({ descriptor: flags.descriptor, ...(flags.root === undefined ? {} : { root: flags.root }) }),
28
+ version,
29
+ });
30
+ await server.connect(new StdioServerTransport());
31
+ const close = () => {
32
+ void server.close();
33
+ };
34
+ process.stdin.once('end', close);
35
+ process.once('SIGINT', close);
36
+ process.once('SIGTERM', close);
37
+ }
@@ -0,0 +1,54 @@
1
+ import { resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver';
2
+ import {
3
+ createMacOSPrivateStorage,
4
+ createMacOSServiceVerifier,
5
+ createWindowsPrivateStorage,
6
+ createWindowsServiceVerifier,
7
+ readConnectionDescriptor,
8
+ } from '@fluentui-react-native/desktop-driver/authority';
9
+ import { createDesktopDriverClient } from '@fluentui-react-native/desktop-driver/client';
10
+ import type { DesktopDriverClient } from '@fluentui-react-native/desktop-driver/client';
11
+
12
+ export type DesktopDriverConnection = {
13
+ client: DesktopDriverClient;
14
+ /** The verified service incarnation. Never a URL or a credential. */
15
+ incarnation: string;
16
+ /** The non-secret grant id, not its bearer token or display label. */
17
+ principal: string;
18
+ };
19
+
20
+ /** Revalidates private discovery and returns the client and its verified authority, without launching anything. */
21
+ export type ConnectDesktopDriver = () => Promise<DesktopDriverConnection>;
22
+
23
+ export type DescriptorConnectionOptions = {
24
+ /** The record name the supervisor published the connection descriptor under, such as `agent-connection.json`. */
25
+ descriptor: string;
26
+ /** The private storage root, when the supervisor published somewhere other than the platform default. */
27
+ root?: string;
28
+ };
29
+
30
+ /**
31
+ * Connects through the private descriptor a trusted supervisor published. The service's identity is verified by the
32
+ * installed native helper before the grant is sent. Nothing is launched or built: a missing helper, descriptor, or
33
+ * service is an error.
34
+ */
35
+ export function connectWithDescriptor(options: DescriptorConnectionOptions): ConnectDesktopDriver {
36
+ return async () => {
37
+ const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : undefined;
38
+ if (platform === undefined) {
39
+ throw new Error('Desktop Driver services run on macOS and Windows.');
40
+ }
41
+ const artifact = await resolveNativeDesktopDriver({ buildPolicy: 'never', platform });
42
+ const storage =
43
+ platform === 'macos'
44
+ ? await createMacOSPrivateStorage(...(options.root === undefined ? [] : [options.root]))
45
+ : await createWindowsPrivateStorage({ artifact, ...(options.root === undefined ? {} : { root: options.root }) });
46
+ const verify = platform === 'macos' ? createMacOSServiceVerifier({ artifact }) : createWindowsServiceVerifier({ artifact });
47
+ const descriptor = await readConnectionDescriptor(storage, options.descriptor, verify);
48
+ return {
49
+ client: createDesktopDriverClient({ grant: descriptor.grant, url: descriptor.url }),
50
+ incarnation: descriptor.service.incarnation,
51
+ principal: descriptor.grant.split('.')[1],
52
+ };
53
+ };
54
+ }
@@ -0,0 +1,351 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { DesktopSessionClient } from '@fluentui-react-native/desktop-driver/client';
4
+ import type { OperationCallOptions } from '@fluentui-react-native/desktop-driver/client';
5
+ import type { ArtifactResult, OperationResponse } from '@fluentui-react-native/desktop-driver/operations';
6
+
7
+ import type { ConnectDesktopDriver, DesktopDriverConnection } from './connection.js';
8
+
9
+ /** A failure the adapter itself reports, with a machine reason as the service's own errors carry. */
10
+ export class AdapterError extends Error {
11
+ readonly reason: string;
12
+
13
+ constructor(reason: string, message: string) {
14
+ super(message);
15
+ this.name = 'AdapterError';
16
+ this.reason = reason;
17
+ }
18
+ }
19
+
20
+ export type TargetSummary = {
21
+ endpoint?: string;
22
+ id: string;
23
+ launchModes?: ('launch' | 'attach')[];
24
+ platformName?: string;
25
+ quarantined?: true;
26
+ renderer?: string;
27
+ };
28
+
29
+ export type OperationSummary = { available: boolean; name: string; unavailableReason?: string };
30
+
31
+ export type SessionSummary = { clickMode?: string; id: string; operations: OperationSummary[]; target: string };
32
+
33
+ export type SessionResponse<Result> = { response: OperationResponse<Result>; session: string };
34
+
35
+ export type OpenSessionRequest = { clickMode?: 'accessibility' | 'auto' | 'physical'; launch: boolean; target: string };
36
+
37
+ type OpenSession = { connection: DesktopDriverConnection; session: DesktopSessionClient; target: string };
38
+
39
+ const deleteTimeoutMs = 10_000;
40
+ const artifactPageBytes = 1024 * 1024;
41
+ /** The service never keeps a larger capture; a larger claim is a fault, not something to buffer. */
42
+ const maxArtifactBytes = 16 * 1024 * 1024;
43
+
44
+ /**
45
+ * Serializes one MCP connection's client lifetime. Transport failure retains cleanup responsibility; only verified
46
+ * replacement or an authoritative rejection invalidates it. No failed mutation or ambiguous acquisition is replayed.
47
+ */
48
+ export class DesktopController {
49
+ readonly #connect: ConnectDesktopDriver;
50
+ #connection?: DesktopDriverConnection;
51
+ #open?: OpenSession;
52
+ #uncertain?: DesktopDriverConnection;
53
+ #pending: Promise<unknown> = Promise.resolve();
54
+ #disposed = false;
55
+ #disposing?: Promise<void>;
56
+ readonly #disconnected = new AbortController();
57
+
58
+ constructor(connect: ConnectDesktopDriver) {
59
+ this.#connect = connect;
60
+ }
61
+
62
+ status(): Promise<{ session?: SessionSummary; sessionState?: 'uncertain'; targets: TargetSummary[] }> {
63
+ return this.enqueue(async () => {
64
+ const { client } = await this.connection();
65
+ const status = await this.guard(() => client.status());
66
+ const targets = Array.isArray(status.targets) ? status.targets.map(summarizeTarget) : [];
67
+ const session = this.#open ? await this.describe(this.#open) : undefined;
68
+ return { ...(session ? { session } : {}), ...(this.#uncertain ? { sessionState: 'uncertain' as const } : {}), targets };
69
+ });
70
+ }
71
+
72
+ open(request: OpenSessionRequest): Promise<SessionSummary> {
73
+ return this.enqueue(async () => {
74
+ const connection = await this.connection();
75
+ if (this.#disposed) throw new AdapterError('connection-closed', 'The MCP connection is closed.');
76
+ this.assertCertain();
77
+ if (this.#open) {
78
+ throw new AdapterError('session-open', 'A session is already open. Close it first.');
79
+ }
80
+ // A lost response can hide a successful acquisition. Retain the fence until a definitive response or replacement.
81
+ this.#uncertain = connection;
82
+ let session: DesktopSessionClient;
83
+ try {
84
+ session = await this.guard(() =>
85
+ connection.client.newSession({
86
+ alwaysMatch: {
87
+ 'furn:launchMode': request.launch ? 'launch' : 'attach',
88
+ 'furn:target': request.target,
89
+ ...(request.clickMode === undefined ? {} : { 'furn:clickMode': request.clickMode }),
90
+ },
91
+ }),
92
+ );
93
+ } catch (error) {
94
+ if (isWebDriverError(error)) this.#uncertain = undefined;
95
+ throw error;
96
+ }
97
+ this.#uncertain = undefined;
98
+ this.#open = { connection, session, target: request.target };
99
+ if (this.#disposed) {
100
+ await this.closeSession();
101
+ throw new AdapterError('connection-closed', 'The MCP connection is closed.');
102
+ }
103
+ return this.describe(this.#open);
104
+ });
105
+ }
106
+
107
+ close(): Promise<{ closed?: string }> {
108
+ return this.enqueue(async () => {
109
+ if (!this.#open && !this.#uncertain) return {};
110
+ await this.connection();
111
+ this.assertCertain();
112
+ return this.closeSession();
113
+ });
114
+ }
115
+
116
+ private async closeSession(): Promise<{ closed?: string }> {
117
+ const open = this.#open;
118
+ if (!open) {
119
+ return {};
120
+ }
121
+ try {
122
+ await this.guard(() => open.session.delete());
123
+ } catch (error) {
124
+ if (!isWebDriverError(error) || error.code !== 'invalid session id') throw error;
125
+ }
126
+ this.#open = undefined;
127
+ return { closed: open.session.id };
128
+ }
129
+
130
+ /** Runs one operation in the open session, and names the session so a later reference can be tied to it. */
131
+ run<Result>(name: string, input: object, options: OperationCallOptions = {}): Promise<SessionResponse<Result>> {
132
+ return this.enqueue(async () => {
133
+ if (this.#open || this.#uncertain) await this.connection();
134
+ this.assertCertain();
135
+ return this.runOperation<Result>(name, input, options);
136
+ });
137
+ }
138
+
139
+ private async runOperation<Result>(name: string, input: object, options: OperationCallOptions): Promise<SessionResponse<Result>> {
140
+ const open = this.#open;
141
+ if (!open) {
142
+ throw new AdapterError('no-session', 'No session is open. Open one with desktop_session first.');
143
+ }
144
+ const signal = AbortSignal.any([this.#disconnected.signal, ...(options.signal ? [options.signal] : [])]);
145
+ const response = await this.guard(() => open.session.operation<Result>(name, input, { ...options, signal }), signal);
146
+ return { response, session: open.session.id };
147
+ }
148
+
149
+ /**
150
+ * Reads a whole captured image by paging through `artifact`. Artifact ids restart in every session, so a reference
151
+ * from a closed session is refused rather than read from whichever session is open now.
152
+ */
153
+ readArtifact(session: string, artifact: string, signal?: AbortSignal): Promise<{ data: Buffer; mimeType: string }> {
154
+ return this.enqueue(async () => {
155
+ if (this.#open || this.#uncertain) await this.connection();
156
+ this.assertCertain();
157
+ return this.readArtifactPages(session, artifact, signal);
158
+ });
159
+ }
160
+
161
+ private async readArtifactPages(session: string, artifact: string, signal?: AbortSignal): Promise<{ data: Buffer; mimeType: string }> {
162
+ if (this.#open?.session.id !== session) {
163
+ throw new AdapterError('artifact-expired', 'That capture belongs to a session that is no longer open.');
164
+ }
165
+ const pages: Buffer[] = [];
166
+ let offset: number | undefined = 0;
167
+ let first: ArtifactResult | undefined;
168
+ let total = 0;
169
+ while (offset !== undefined) {
170
+ const { response } = await this.runOperation<ArtifactResult>('artifact', { artifact, length: artifactPageBytes, offset }, { signal });
171
+ const page = response.result;
172
+ first ??= page;
173
+ if (page.sha256 !== first.sha256 || page.bytes > maxArtifactBytes) {
174
+ throw new AdapterError('artifact-changed', 'The capture changed while it was being read.');
175
+ }
176
+ const bytes = Buffer.from(page.data, 'base64');
177
+ total += bytes.byteLength;
178
+ if (total > page.bytes || (page.next !== undefined && page.next <= offset)) {
179
+ throw new AdapterError('artifact-changed', 'The capture changed while it was being read.');
180
+ }
181
+ pages.push(bytes);
182
+ offset = page.next;
183
+ }
184
+ const data = Buffer.concat(pages);
185
+ if (!first || data.byteLength !== first.bytes || createHash('sha256').update(data).digest('hex') !== first.sha256) {
186
+ throw new AdapterError('artifact-changed', 'The capture did not match its recorded digest.');
187
+ }
188
+ return { data, mimeType: first.mimeType };
189
+ }
190
+
191
+ get session(): string | undefined {
192
+ return this.#open?.session.id;
193
+ }
194
+
195
+ /** Stops admissions immediately. Late acquisitions still run their cleanup after the bounded close wait expires. */
196
+ dispose(): Promise<void> {
197
+ this.#disposed = true;
198
+ this.#disconnected.abort();
199
+ return (this.#disposing ??= (async () => {
200
+ const cleanup = this.#pending.then(async () => {
201
+ await this.closeSession();
202
+ this.assertCertain();
203
+ });
204
+ this.#pending = cleanup.catch(() => {
205
+ console.error('desktop-driver-mcp: session cleanup is incomplete; the service must drain or expire the session.');
206
+ });
207
+ let timer: ReturnType<typeof setTimeout> | undefined;
208
+ try {
209
+ await Promise.race([
210
+ this.#pending,
211
+ new Promise<void>((resolve) => {
212
+ timer = setTimeout(() => {
213
+ console.error('desktop-driver-mcp: session cleanup is still pending; the service must drain or expire the session.');
214
+ resolve();
215
+ }, deleteTimeoutMs);
216
+ timer.unref();
217
+ }),
218
+ ]);
219
+ } finally {
220
+ if (timer) clearTimeout(timer);
221
+ }
222
+ })());
223
+ }
224
+
225
+ private async connection(): Promise<DesktopDriverConnection> {
226
+ if (this.#connection) return this.#connection;
227
+ let connection: DesktopDriverConnection;
228
+ try {
229
+ connection = await this.#connect();
230
+ } catch (error) {
231
+ // The cause can name machine-local paths; the operator reads it on stderr, the model reads a fixed message.
232
+ console.error(`desktop-driver-mcp: connecting failed: ${error instanceof Error ? error.message : String(error)}`);
233
+ throw new AdapterError('service-unavailable', 'The Desktop Driver service is not available. Ask the operator to start it.');
234
+ }
235
+ const previous = this.#open?.connection ?? this.#uncertain;
236
+ if (previous && previous.incarnation !== connection.incarnation) {
237
+ this.#open = undefined;
238
+ this.#uncertain = undefined;
239
+ } else if (previous && previous.principal !== connection.principal) {
240
+ // A new grant on the same live service does not prove that the old grant's session has ended.
241
+ if (this.#open) {
242
+ try {
243
+ await this.closeSession();
244
+ } catch (error) {
245
+ if (this.#open) throw error;
246
+ }
247
+ }
248
+ if (this.#uncertain) {
249
+ try {
250
+ await this.guard(() => previous.client.status());
251
+ } catch (error) {
252
+ if (this.#uncertain) throw error;
253
+ }
254
+ }
255
+ this.assertCertain();
256
+ } else if (this.#open) {
257
+ // Rebind only to the same verified service and principal. A subsequent command still validates session liveness.
258
+ this.#open = {
259
+ ...this.#open,
260
+ connection,
261
+ session: new DesktopSessionClient(connection.client, this.#open.session.id, this.#open.session.capabilities),
262
+ };
263
+ }
264
+ this.#connection = connection;
265
+ return connection;
266
+ }
267
+
268
+ private enqueue<T>(work: () => Promise<T>): Promise<T> {
269
+ const result = this.#pending.then(() => {
270
+ if (this.#disposed) throw new AdapterError('connection-closed', 'The MCP connection is closed.');
271
+ return work();
272
+ });
273
+ this.#pending = result.catch(() => undefined);
274
+ return result;
275
+ }
276
+
277
+ private assertCertain(): void {
278
+ if (this.#uncertain) {
279
+ throw new AdapterError(
280
+ 'session-uncertain',
281
+ 'A session acquisition lost its response. Ask the operator to revoke the old grant or restart the service before opening another.',
282
+ );
283
+ }
284
+ }
285
+
286
+ private async describe(open: OpenSession): Promise<SessionSummary> {
287
+ const catalog = await this.guard(() => open.session.operations());
288
+ const operations = catalog.operations.map(({ available, name, unavailableReason }) => ({
289
+ available,
290
+ name,
291
+ ...(unavailableReason === undefined ? {} : { unavailableReason }),
292
+ }));
293
+ const clickMode = open.session.capabilities['furn:clickMode'];
294
+ return {
295
+ ...(typeof clickMode === 'string' ? { clickMode } : {}),
296
+ id: open.session.id,
297
+ operations,
298
+ target: open.target,
299
+ };
300
+ }
301
+
302
+ /** Transport errors retain ownership; authoritative invalidation never permits replaying a failed operation. */
303
+ private async guard<T>(call: () => Promise<T>, signal?: AbortSignal): Promise<T> {
304
+ try {
305
+ return await call();
306
+ } catch (error) {
307
+ if (signal?.aborted) {
308
+ throw new AdapterError('cancelled', 'The call was cancelled.');
309
+ }
310
+ if (isWebDriverError(error)) {
311
+ if (error.code === 'invalid session id') {
312
+ this.#open = undefined;
313
+ }
314
+ if (error.code === 'unauthorized') {
315
+ this.#open = undefined;
316
+ this.#uncertain = undefined;
317
+ this.#connection = undefined;
318
+ throw new AdapterError(
319
+ 'service-unavailable',
320
+ 'The Desktop Driver authority is no longer valid. Reconnect before opening a session.',
321
+ );
322
+ }
323
+ throw error;
324
+ }
325
+ this.#connection = undefined;
326
+ throw new AdapterError('service-unavailable', 'The Desktop Driver service stopped answering. Recheck status before continuing.');
327
+ }
328
+ }
329
+ }
330
+
331
+ export function isWebDriverError(error: unknown): error is Error & { code: string; data?: Record<string, unknown> } {
332
+ return error instanceof Error && error.name === 'WebDriverError' && typeof (error as { code?: unknown }).code === 'string';
333
+ }
334
+
335
+ function summarizeTarget(target: Record<string, unknown>): TargetSummary {
336
+ const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
337
+ const summary: TargetSummary = { id: String(target.id) };
338
+ for (const key of ['endpoint', 'platformName', 'renderer'] as const) {
339
+ const value = text(target[key]);
340
+ if (value !== undefined) {
341
+ summary[key] = value;
342
+ }
343
+ }
344
+ if (target.quarantine !== undefined) {
345
+ summary.quarantined = true;
346
+ }
347
+ if (Array.isArray(target.launchModes)) {
348
+ summary.launchModes = target.launchModes.filter((mode): mode is 'launch' | 'attach' => mode === 'launch' || mode === 'attach');
349
+ }
350
+ return summary;
351
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { connectWithDescriptor } from './connection.js';
2
+ export type { ConnectDesktopDriver, DescriptorConnectionOptions, DesktopDriverConnection } from './connection.js';
3
+ export { createDesktopDriverMcpServer } from './server.js';
4
+ export type { DesktopDriverMcpServerOptions } from './server.js';