@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.
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +57 -0
- package/README.md +49 -2
- package/config/cli.cjs +10 -0
- package/lib/cli.d.ts +10 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +30 -0
- package/lib/cli.js.map +1 -0
- package/lib/connection.d.ts +23 -0
- package/lib/connection.d.ts.map +1 -0
- package/lib/connection.js +28 -0
- package/lib/connection.js.map +1 -0
- package/lib/controller.d.ts +80 -0
- package/lib/controller.d.ts.map +1 -0
- package/lib/controller.js +352 -0
- package/lib/controller.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/results.d.ts +11 -0
- package/lib/results.d.ts.map +1 -0
- package/lib/results.js +61 -0
- package/lib/results.js.map +1 -0
- package/lib/server.d.ts +13 -0
- package/lib/server.d.ts.map +1 -0
- package/lib/server.js +247 -0
- package/lib/server.js.map +1 -0
- package/package.json +55 -3
- package/references/README.md +14 -0
- package/references/connection.md +162 -0
- package/references/current-status.md +86 -0
- package/references/tools.md +134 -0
- package/src/cli.ts +37 -0
- package/src/connection.ts +54 -0
- package/src/controller.ts +381 -0
- package/src/index.ts +4 -0
- package/src/results.ts +66 -0
- package/src/server.ts +330 -0
|
@@ -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
|
+
}
|