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

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,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/native';
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,381 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { DesktopSessionClient } from '@fluentui-react-native/desktop-driver/client';
4
+ import type { DesktopDriverClient, 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
+ #binding?: { root: DesktopDriverConnection; child: DesktopDriverConnection };
52
+ #open?: OpenSession;
53
+ #uncertain?: DesktopDriverConnection;
54
+ #pending: Promise<unknown> = Promise.resolve();
55
+ #disposed = false;
56
+ #disposing?: Promise<void>;
57
+ readonly #disconnected = new AbortController();
58
+
59
+ constructor(connect: ConnectDesktopDriver) {
60
+ this.#connect = connect;
61
+ }
62
+
63
+ status(): Promise<{ session?: SessionSummary; sessionState?: 'uncertain'; targets: TargetSummary[] }> {
64
+ return this.enqueue(async () => {
65
+ const { client } = await this.connection();
66
+ const status = await this.guard(() => client.status());
67
+ const targets = Array.isArray(status.targets) ? status.targets.map(summarizeTarget) : [];
68
+ const session = this.#open ? await this.describe(this.#open) : undefined;
69
+ return { ...(session ? { session } : {}), ...(this.#uncertain ? { sessionState: 'uncertain' as const } : {}), targets };
70
+ });
71
+ }
72
+
73
+ open(request: OpenSessionRequest): Promise<SessionSummary> {
74
+ return this.enqueue(async () => {
75
+ const connection = await this.connection();
76
+ if (this.#disposed) throw new AdapterError('connection-closed', 'The MCP connection is closed.');
77
+ this.assertCertain();
78
+ if (this.#open) {
79
+ throw new AdapterError('session-open', 'A session is already open. Close it first.');
80
+ }
81
+ // A lost response can hide a successful acquisition. Retain the fence until a definitive response or replacement.
82
+ this.#uncertain = connection;
83
+ let session: DesktopSessionClient;
84
+ try {
85
+ session = await this.guard(() =>
86
+ connection.client.newSession({
87
+ alwaysMatch: {
88
+ 'furn:launchMode': request.launch ? 'launch' : 'attach',
89
+ 'furn:target': request.target,
90
+ ...(request.clickMode === undefined ? {} : { 'furn:clickMode': request.clickMode }),
91
+ },
92
+ }),
93
+ );
94
+ } catch (error) {
95
+ if (isWebDriverError(error)) this.#uncertain = undefined;
96
+ throw error;
97
+ }
98
+ this.#uncertain = undefined;
99
+ this.#open = { connection, session, target: request.target };
100
+ if (this.#disposed) {
101
+ await this.closeSession();
102
+ throw new AdapterError('connection-closed', 'The MCP connection is closed.');
103
+ }
104
+ return this.describe(this.#open);
105
+ });
106
+ }
107
+
108
+ close(): Promise<{ closed?: string }> {
109
+ return this.enqueue(async () => {
110
+ if (!this.#open && !this.#uncertain) return {};
111
+ await this.connection();
112
+ this.assertCertain();
113
+ return this.closeSession();
114
+ });
115
+ }
116
+
117
+ private async closeSession(): Promise<{ closed?: string }> {
118
+ const open = this.#open;
119
+ if (!open) {
120
+ return {};
121
+ }
122
+ try {
123
+ await this.guard(() => open.session.delete());
124
+ } catch (error) {
125
+ if (!isWebDriverError(error) || error.code !== 'invalid session id') throw error;
126
+ }
127
+ this.#open = undefined;
128
+ return { closed: open.session.id };
129
+ }
130
+
131
+ /** Runs one operation in the open session, and names the session so a later reference can be tied to it. */
132
+ run<Result>(name: string, input: object, options: OperationCallOptions = {}): Promise<SessionResponse<Result>> {
133
+ return this.enqueue(async () => {
134
+ if (this.#open || this.#uncertain) await this.connection();
135
+ this.assertCertain();
136
+ return this.runOperation<Result>(name, input, options);
137
+ });
138
+ }
139
+
140
+ private async runOperation<Result>(name: string, input: object, options: OperationCallOptions): Promise<SessionResponse<Result>> {
141
+ const open = this.#open;
142
+ if (!open) {
143
+ throw new AdapterError('no-session', 'No session is open. Open one with desktop_session first.');
144
+ }
145
+ const signal = AbortSignal.any([this.#disconnected.signal, ...(options.signal ? [options.signal] : [])]);
146
+ const response = await this.guard(() => open.session.operation<Result>(name, input, { ...options, signal }), signal);
147
+ return { response, session: open.session.id };
148
+ }
149
+
150
+ /**
151
+ * Reads a whole captured image by paging through `artifact`. Artifact ids restart in every session, so a reference
152
+ * from a closed session is refused rather than read from whichever session is open now.
153
+ */
154
+ readArtifact(session: string, artifact: string, signal?: AbortSignal): Promise<{ data: Buffer; mimeType: string }> {
155
+ return this.enqueue(async () => {
156
+ if (this.#open || this.#uncertain) await this.connection();
157
+ this.assertCertain();
158
+ return this.readArtifactPages(session, artifact, signal);
159
+ });
160
+ }
161
+
162
+ private async readArtifactPages(session: string, artifact: string, signal?: AbortSignal): Promise<{ data: Buffer; mimeType: string }> {
163
+ if (this.#open?.session.id !== session) {
164
+ throw new AdapterError('artifact-expired', 'That capture belongs to a session that is no longer open.');
165
+ }
166
+ const pages: Buffer[] = [];
167
+ let offset: number | undefined = 0;
168
+ let first: ArtifactResult | undefined;
169
+ let total = 0;
170
+ while (offset !== undefined) {
171
+ const { response } = await this.runOperation<ArtifactResult>('artifact', { artifact, length: artifactPageBytes, offset }, { signal });
172
+ const page = response.result;
173
+ first ??= page;
174
+ if (page.sha256 !== first.sha256 || page.bytes > maxArtifactBytes) {
175
+ throw new AdapterError('artifact-changed', 'The capture changed while it was being read.');
176
+ }
177
+ const bytes = Buffer.from(page.data, 'base64');
178
+ total += bytes.byteLength;
179
+ if (total > page.bytes || (page.next !== undefined && page.next <= offset)) {
180
+ throw new AdapterError('artifact-changed', 'The capture changed while it was being read.');
181
+ }
182
+ pages.push(bytes);
183
+ offset = page.next;
184
+ }
185
+ const data = Buffer.concat(pages);
186
+ if (!first || data.byteLength !== first.bytes || createHash('sha256').update(data).digest('hex') !== first.sha256) {
187
+ throw new AdapterError('artifact-changed', 'The capture did not match its recorded digest.');
188
+ }
189
+ return { data, mimeType: first.mimeType };
190
+ }
191
+
192
+ get session(): string | undefined {
193
+ return this.#open?.session.id;
194
+ }
195
+
196
+ /** Stops admissions immediately. Late acquisitions still run their cleanup after the bounded close wait expires. */
197
+ dispose(): Promise<void> {
198
+ this.#disposed = true;
199
+ this.#disconnected.abort();
200
+ return (this.#disposing ??= (async () => {
201
+ const cleanup = this.#pending.then(async () => {
202
+ if (this.#binding) {
203
+ await this.#binding.child.client.revokeConnection();
204
+ this.#open = undefined;
205
+ this.#uncertain = undefined;
206
+ this.#binding = undefined;
207
+ }
208
+ });
209
+ this.#pending = cleanup.catch(() => {
210
+ console.error('desktop-driver-mcp: session cleanup is incomplete; the service must drain or expire the session.');
211
+ });
212
+ let timer: ReturnType<typeof setTimeout> | undefined;
213
+ try {
214
+ await Promise.race([
215
+ this.#pending,
216
+ new Promise<void>((resolve) => {
217
+ timer = setTimeout(() => {
218
+ console.error('desktop-driver-mcp: session cleanup is still pending; the service must drain or expire the session.');
219
+ resolve();
220
+ }, deleteTimeoutMs);
221
+ timer.unref();
222
+ }),
223
+ ]);
224
+ } finally {
225
+ if (timer) clearTimeout(timer);
226
+ if (this.#binding) this.#disposing = undefined;
227
+ }
228
+ })());
229
+ }
230
+
231
+ private async connection(): Promise<DesktopDriverConnection> {
232
+ if (this.#connection) return this.#connection;
233
+ let connection: DesktopDriverConnection;
234
+ try {
235
+ connection = await this.#connect();
236
+ } catch {
237
+ // Discovery failures can contain descriptor credentials; neither protocol output nor diagnostics may echo them.
238
+ console.error('desktop-driver-mcp: connecting failed; verify private discovery and the service identity.');
239
+ throw new AdapterError('service-unavailable', 'The Desktop Driver service is not available. Ask the operator to start it.');
240
+ }
241
+ const binding = this.#binding;
242
+ if (binding && binding.root.incarnation === connection.incarnation && binding.root.principal === connection.principal) {
243
+ connection = binding.child;
244
+ } else {
245
+ if (binding && binding.root.incarnation === connection.incarnation) {
246
+ await this.guard(() => binding.child.client.revokeConnection());
247
+ this.#open = undefined;
248
+ this.#uncertain = undefined;
249
+ }
250
+ const root = connection;
251
+ let child: DesktopDriverClient;
252
+ try {
253
+ child = await this.guard(() => root.client.delegateConnection());
254
+ } catch (error) {
255
+ if (isWebDriverError(error) && ['unknown command', 'unsupported operation'].includes(error.code)) {
256
+ throw new AdapterError('connection-authority-unsupported', 'The service does not support isolated connection authority.');
257
+ }
258
+ throw error;
259
+ }
260
+ const principal = child.principalId;
261
+ if (!principal) throw new AdapterError('service-unavailable', 'The service does not support isolated connection authority.');
262
+ connection = { client: child, incarnation: root.incarnation, principal };
263
+ this.#binding = { child: connection, root };
264
+ }
265
+ const previous = this.#open?.connection ?? this.#uncertain;
266
+ if (previous && previous.incarnation !== connection.incarnation) {
267
+ this.#open = undefined;
268
+ this.#uncertain = undefined;
269
+ } else if (previous && previous.principal !== connection.principal) {
270
+ // A new grant on the same live service does not prove that the old grant's session has ended.
271
+ if (this.#open) {
272
+ try {
273
+ await this.closeSession();
274
+ } catch (error) {
275
+ if (this.#open) throw error;
276
+ }
277
+ }
278
+ if (this.#uncertain) {
279
+ try {
280
+ await this.guard(() => previous.client.status());
281
+ } catch (error) {
282
+ if (this.#uncertain) throw error;
283
+ }
284
+ }
285
+ this.assertCertain();
286
+ } else if (this.#open) {
287
+ // Rebind only to the same verified service and principal. A subsequent command still validates session liveness.
288
+ this.#open = {
289
+ ...this.#open,
290
+ connection,
291
+ session: new DesktopSessionClient(connection.client, this.#open.session.id, this.#open.session.capabilities),
292
+ };
293
+ }
294
+ this.#connection = connection;
295
+ return connection;
296
+ }
297
+
298
+ private enqueue<T>(work: () => Promise<T>): Promise<T> {
299
+ const result = this.#pending.then(() => {
300
+ if (this.#disposed) throw new AdapterError('connection-closed', 'The MCP connection is closed.');
301
+ return work();
302
+ });
303
+ this.#pending = result.catch(() => undefined);
304
+ return result;
305
+ }
306
+
307
+ private assertCertain(): void {
308
+ if (this.#uncertain) {
309
+ throw new AdapterError(
310
+ 'session-uncertain',
311
+ 'A session acquisition lost its response. Ask the operator to revoke the old grant or restart the service before opening another.',
312
+ );
313
+ }
314
+ }
315
+
316
+ private async describe(open: OpenSession): Promise<SessionSummary> {
317
+ const catalog = await this.guard(() => open.session.operations());
318
+ const operations = catalog.operations.map(({ available, name, unavailableReason }) => ({
319
+ available,
320
+ name,
321
+ ...(unavailableReason === undefined ? {} : { unavailableReason }),
322
+ }));
323
+ const clickMode = open.session.capabilities['furn:clickMode'];
324
+ return {
325
+ ...(typeof clickMode === 'string' ? { clickMode } : {}),
326
+ id: open.session.id,
327
+ operations,
328
+ target: open.target,
329
+ };
330
+ }
331
+
332
+ /** Transport errors retain ownership; authoritative invalidation never permits replaying a failed operation. */
333
+ private async guard<T>(call: () => Promise<T>, signal?: AbortSignal): Promise<T> {
334
+ try {
335
+ return await call();
336
+ } catch (error) {
337
+ if (signal?.aborted) {
338
+ throw new AdapterError('cancelled', 'The call was cancelled.');
339
+ }
340
+ if (isWebDriverError(error)) {
341
+ if (error.code === 'invalid session id') {
342
+ this.#open = undefined;
343
+ }
344
+ if (error.code === 'unauthorized') {
345
+ this.#open = undefined;
346
+ this.#uncertain = undefined;
347
+ this.#connection = undefined;
348
+ throw new AdapterError(
349
+ 'service-unavailable',
350
+ 'The Desktop Driver authority is no longer valid. Reconnect before opening a session.',
351
+ );
352
+ }
353
+ throw error;
354
+ }
355
+ this.#connection = undefined;
356
+ throw new AdapterError('service-unavailable', 'The Desktop Driver service stopped answering. Recheck status before continuing.');
357
+ }
358
+ }
359
+ }
360
+
361
+ export function isWebDriverError(error: unknown): error is Error & { code: string; data?: Record<string, unknown> } {
362
+ return error instanceof Error && error.name === 'WebDriverError' && typeof (error as { code?: unknown }).code === 'string';
363
+ }
364
+
365
+ function summarizeTarget(target: Record<string, unknown>): TargetSummary {
366
+ const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
367
+ const summary: TargetSummary = { id: String(target.id) };
368
+ for (const key of ['endpoint', 'platformName', 'renderer'] as const) {
369
+ const value = text(target[key]);
370
+ if (value !== undefined) {
371
+ summary[key] = value;
372
+ }
373
+ }
374
+ if (target.quarantine !== undefined) {
375
+ summary.quarantined = true;
376
+ }
377
+ if (Array.isArray(target.launchModes)) {
378
+ summary.launchModes = target.launchModes.filter((mode): mode is 'launch' | 'attach' => mode === 'launch' || mode === 'attach');
379
+ }
380
+ return summary;
381
+ }
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';
package/src/results.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+
3
+ import { AdapterError, isWebDriverError } from './controller.js';
4
+
5
+ type Content = CallToolResult['content'][number];
6
+
7
+ const maxMessageLength = 300;
8
+
9
+ /**
10
+ * Renders a value as the model reads it: compact JSON, with any observation outline lifted out into its own text block
11
+ * so its lines read as lines instead of escaped `\n` runs. Nothing is sent twice.
12
+ */
13
+ export function jsonResult(value: unknown, extra: Content[] = []): CallToolResult {
14
+ const outlines: string[] = [];
15
+ const lifted = liftOutlines(value, outlines);
16
+ return {
17
+ content: [{ text: JSON.stringify(lifted), type: 'text' }, ...outlines.map((text): Content => ({ text, type: 'text' })), ...extra],
18
+ };
19
+ }
20
+
21
+ /** A failure the model can branch on: the WebDriver error code, the machine reason, and a bounded message. */
22
+ export function errorResult(error: unknown): CallToolResult {
23
+ let failure: Record<string, unknown>;
24
+ if (error instanceof AdapterError) {
25
+ failure = { error: 'adapter', message: error.message, reason: error.reason };
26
+ } else if (isWebDriverError(error)) {
27
+ const data = error.data ?? {};
28
+ failure = {
29
+ error: error.code,
30
+ message: bound(error.message),
31
+ ...pick(data, ['reason', 'nativeCode', 'requestId', 'mutation']),
32
+ };
33
+ } else {
34
+ console.error(`desktop-driver-mcp: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
35
+ failure = { error: 'adapter', message: 'The adapter failed unexpectedly.', reason: 'internal' };
36
+ }
37
+ return { content: [{ text: JSON.stringify(failure), type: 'text' }], isError: true };
38
+ }
39
+
40
+ function liftOutlines(value: unknown, outlines: string[]): unknown {
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
42
+ return value;
43
+ }
44
+ const copy: Record<string, unknown> = {};
45
+ for (const [key, child] of Object.entries(value)) {
46
+ if (key === 'outline' && typeof child === 'string') {
47
+ outlines.push(child);
48
+ copy.outline = `(text block ${outlines.length + 1})`;
49
+ } else if (key === 'result' || key === 'observation') {
50
+ copy[key] = liftOutlines(child, outlines);
51
+ } else {
52
+ copy[key] = child;
53
+ }
54
+ }
55
+ return copy;
56
+ }
57
+
58
+ function pick(data: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> {
59
+ return Object.fromEntries(keys.filter((key) => data[key] !== undefined).map((key) => [key, data[key]]));
60
+ }
61
+
62
+ function bound(message: string): string {
63
+ // The service already bounds operation messages; session errors may quote provider text, so bound them here too.
64
+ const visible = message.replace(/\p{Cc}/gu, ' ');
65
+ return visible.length > maxMessageLength ? `${visible.slice(0, maxMessageLength - 1)}…` : visible;
66
+ }