@devicecloud.dev/dcd 5.11.0-beta.2 → 5.11.0-beta.3
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/README.md +50 -3
- package/dist/commands/device.d.ts +7 -1
- package/dist/commands/device.js +23 -6
- package/dist/commands/login.js +1 -1
- package/dist/commands/mcp.js +1 -1
- package/dist/mcp/bridge.js +2 -2
- package/dist/utils/browser.d.ts +8 -2
- package/dist/utils/browser.js +23 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,9 +36,56 @@ See full documentation: [Docs](https://docs.devicecloud.dev)
|
|
|
36
36
|
|
|
37
37
|
## MCP server
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
devicecloud.dev has an [MCP](https://modelcontextprotocol.io) server, so AI agents (Claude, Cursor, VS Code) can run cloud tests and drive cloud devices. You don't run it yourself. You add it to your MCP client's config, and the client starts it.
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
There are three ways to connect:
|
|
42
|
+
|
|
43
|
+
| | Install | Tools | Use it when |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| **`dcd mcp`** (recommended) | the CLI | device tools + `dcd_*` cloud-test tools | you want everything, including installing a local build or running local flow files |
|
|
46
|
+
| **Hosted** `https://api.devicecloud.dev/mcp` | nothing | device tools | the agent only drives devices, with apps and flows that are already uploaded or at a URL |
|
|
47
|
+
| **`dcd-mcp`** | the CLI | `dcd_*` cloud-test tools | you only run cloud tests (the older server; `dcd mcp` covers it) |
|
|
48
|
+
|
|
49
|
+
### `dcd mcp`
|
|
50
|
+
|
|
51
|
+
`dcd mcp` connects to the hosted server and passes its device tools through. It adds what only a local process can do: `install_app` takes `app_path` (a build on your machine, uploaded first) and `run` takes `path` (a flow file, or a directory uploaded as a bundle). It also serves the `dcd_*` tools below.
|
|
52
|
+
|
|
53
|
+
Claude Code:
|
|
54
|
+
|
|
55
|
+
```sh-session
|
|
56
|
+
$ claude mcp add devicecloud -- npx -y @devicecloud.dev/dcd mcp
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Other clients:
|
|
60
|
+
|
|
61
|
+
```jsonc
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"devicecloud": {
|
|
65
|
+
"command": "npx",
|
|
66
|
+
"args": ["-y", "@devicecloud.dev/dcd", "mcp"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
If the CLI is installed globally, use `"command": "dcd", "args": ["mcp"]` instead.
|
|
73
|
+
|
|
74
|
+
It signs in the same way as the CLI: run `dcd login` once, or set `DEVICE_CLOUD_API_KEY` in `env`. Add `--read-only` to the args (or set `DCD_MCP_READONLY=1`) to serve only the tools that change nothing. If the hosted server can't be reached, it serves the `dcd_*` tools only and says why on stderr.
|
|
75
|
+
|
|
76
|
+
**Device tools:** `list_devices`, `start_device`, `get_device_status`, `install_app`, `launch_app`, `inspect_screen`, `take_screenshot`, `run`, `get_command_status`, `get_logs`, `request_human`, `get_human_status`, `stop_device`, and `cheat_sheet` (Maestro syntax for the agent).
|
|
77
|
+
|
|
78
|
+
### Hosted
|
|
79
|
+
|
|
80
|
+
Point an HTTP-capable client at the URL. It signs you in through the browser (OAuth), and you can disconnect it later under Settings → Connected apps.
|
|
81
|
+
|
|
82
|
+
```sh-session
|
|
83
|
+
$ claude mcp add --transport http devicecloud https://api.devicecloud.dev/mcp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `dcd-mcp`
|
|
87
|
+
|
|
88
|
+
The older server, with the `dcd_*` tools only. Add it to your MCP client config:
|
|
42
89
|
|
|
43
90
|
```jsonc
|
|
44
91
|
{
|
|
@@ -56,7 +103,7 @@ Add it to your MCP client config:
|
|
|
56
103
|
|
|
57
104
|
Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`.
|
|
58
105
|
|
|
59
|
-
**
|
|
106
|
+
**`dcd_*` tools** (served by both `dcd mcp` and `dcd-mcp`)
|
|
60
107
|
|
|
61
108
|
| Tool | What it does |
|
|
62
109
|
| --- | --- |
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { type DeviceCatalogue, type DeviceScreenElement } from '../types/domain/device-session.types.js';
|
|
1
|
+
import { type DeviceCatalogue, type DeviceScreenElement, type DeviceSession } from '../types/domain/device-session.types.js';
|
|
2
2
|
/**
|
|
3
3
|
* The platform a `--model` belongs to, from GET /device/models. Matches the
|
|
4
4
|
* model id (iphone-16) or its name (iPhone 16), ignoring case. Unknown, or on
|
|
5
5
|
* both platforms: a usage error that says to pass --platform.
|
|
6
6
|
*/
|
|
7
7
|
export declare function platformOfModel(catalogue: DeviceCatalogue, model: string): 'android' | 'ios';
|
|
8
|
+
/**
|
|
9
|
+
* The "Next steps" `dcd device start` prints. They follow the session: a
|
|
10
|
+
* device that isn't ready yet can't be inspected, so that comes first, and
|
|
11
|
+
* a session with no app gets the install step.
|
|
12
|
+
*/
|
|
13
|
+
export declare function startNextSteps(session: DeviceSession, hasApp: boolean): [string, string][];
|
|
8
14
|
/** One element on one line: what to target it by, then its bounds. */
|
|
9
15
|
export declare function formatElement(el: DeviceScreenElement): string;
|
|
10
16
|
/** `data:image/jpeg;base64,...` to bytes and a file extension. */
|
package/dist/commands/device.js
CHANGED
|
@@ -159,6 +159,22 @@ export function platformOfModel(catalogue, model) {
|
|
|
159
159
|
}
|
|
160
160
|
throw new CliError(`"${model}" is a model on both iOS and Android. Pass --platform ios or --platform android.`);
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The "Next steps" `dcd device start` prints. They follow the session: a
|
|
164
|
+
* device that isn't ready yet can't be inspected, so that comes first, and
|
|
165
|
+
* a session with no app gets the install step.
|
|
166
|
+
*/
|
|
167
|
+
export function startNextSteps(session, hasApp) {
|
|
168
|
+
const { id } = session;
|
|
169
|
+
const steps = [];
|
|
170
|
+
if (!USABLE_STATES.has(session.state)) {
|
|
171
|
+
steps.push(['check it is ready', `dcd device status ${id}`]);
|
|
172
|
+
}
|
|
173
|
+
if (!hasApp)
|
|
174
|
+
steps.push(['install an app', `dcd device install ${id} <app>`]);
|
|
175
|
+
steps.push(['inspect the screen', `dcd device inspect ${id}`], ['run a flow', `dcd device run ${id} flow.yaml`], ['stop', `dcd device stop ${id}`], ['all commands', 'dcd device --help']);
|
|
176
|
+
return steps;
|
|
177
|
+
}
|
|
162
178
|
const startSub = defineCommand({
|
|
163
179
|
meta: { name: 'start', description: 'Start a device session' },
|
|
164
180
|
args: {
|
|
@@ -289,11 +305,12 @@ const startSub = defineCommand({
|
|
|
289
305
|
logger.log(ui.section('Device session'));
|
|
290
306
|
printSession(session);
|
|
291
307
|
logger.log(ui.section('Next steps'));
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
308
|
+
const hasApp = Boolean(installedId ?? uploadedId);
|
|
309
|
+
const steps = startNextSteps(session, hasApp);
|
|
310
|
+
logger.log(ui.branch(ui.fields(steps.map(([label, cmd]) => [label, colors.highlight(cmd)]))));
|
|
311
|
+
if (!USABLE_STATES.has(session.state)) {
|
|
312
|
+
logger.log(ui.info('Next time, `dcd device start --wait` waits until the device is ready.'));
|
|
313
|
+
}
|
|
297
314
|
},
|
|
298
315
|
};
|
|
299
316
|
});
|
|
@@ -886,7 +903,7 @@ const openSub = defineCommand({
|
|
|
886
903
|
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id);
|
|
887
904
|
const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id);
|
|
888
905
|
// Only a person at a terminal gets a browser; a script gets the URL.
|
|
889
|
-
const opened = interactive(json) && openBrowser(session.monitor_url);
|
|
906
|
+
const opened = interactive(json) && (await openBrowser(session.monitor_url));
|
|
890
907
|
return {
|
|
891
908
|
session,
|
|
892
909
|
data: { url: session.monitor_url, opened },
|
package/dist/commands/login.js
CHANGED
|
@@ -149,7 +149,7 @@ export const loginCommand = defineCommand({
|
|
|
149
149
|
rows.push(ui.note('Open this URL in a browser to finish login:'), colors.highlight(loginUrl));
|
|
150
150
|
}
|
|
151
151
|
else {
|
|
152
|
-
const opened = openBrowser(loginUrl);
|
|
152
|
+
const opened = await openBrowser(loginUrl);
|
|
153
153
|
rows.push(ui.note(opened
|
|
154
154
|
? 'Opening your browser…'
|
|
155
155
|
: 'Could not launch a browser. Open this URL manually:'));
|
package/dist/commands/mcp.js
CHANGED
|
@@ -11,7 +11,7 @@ import { telemetry } from '../services/telemetry.service.js';
|
|
|
11
11
|
export const mcpCommand = defineCommand({
|
|
12
12
|
meta: {
|
|
13
13
|
name: 'mcp',
|
|
14
|
-
description: '
|
|
14
|
+
description: 'MCP server for AI agents, started by your MCP client (Claude, Cursor, VS Code)',
|
|
15
15
|
},
|
|
16
16
|
args: {
|
|
17
17
|
'read-only': {
|
package/dist/mcp/bridge.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* a bundle).
|
|
11
11
|
*
|
|
12
12
|
* Auth is the CLI's: an API key or a `dcd login` session (getContext). The
|
|
13
|
-
* hosted endpoint accepts both. A session is renewed before it expires
|
|
14
|
-
*
|
|
13
|
+
* hosted endpoint accepts both. A session is renewed before it expires; the
|
|
14
|
+
* one shared connection re-reads the auth on every request (RemoteDevices).
|
|
15
15
|
*
|
|
16
16
|
* stdout is the JSON-RPC channel: everything else goes to stderr.
|
|
17
17
|
*/
|
package/dist/utils/browser.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Open `url` in the default browser. False if it isn't an http(s) URL or no
|
|
3
|
-
* opener could be started.
|
|
3
|
+
* opener could be started (none installed, as on a headless box).
|
|
4
4
|
*
|
|
5
5
|
* Only http(s) is opened, so a URL can't name a local file or start with `-`
|
|
6
6
|
* and be read as an option. No opener goes through a shell: Windows uses
|
|
7
7
|
* rundll32, because `cmd /c start` parses the URL again and treats its `&`
|
|
8
8
|
* as a command separator.
|
|
9
9
|
*/
|
|
10
|
-
export declare function openBrowser(url: string): boolean
|
|
10
|
+
export declare function openBrowser(url: string): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Start `bin` detached. False if it couldn't be started. A missing program is
|
|
13
|
+
* reported as an 'error' event, not thrown, and with no listener it would
|
|
14
|
+
* crash the process, so this waits for 'spawn' or 'error' to know.
|
|
15
|
+
*/
|
|
16
|
+
export declare function startOpener(bin: string, args: string[]): Promise<boolean>;
|
package/dist/utils/browser.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
/**
|
|
3
3
|
* Open `url` in the default browser. False if it isn't an http(s) URL or no
|
|
4
|
-
* opener could be started.
|
|
4
|
+
* opener could be started (none installed, as on a headless box).
|
|
5
5
|
*
|
|
6
6
|
* Only http(s) is opened, so a URL can't name a local file or start with `-`
|
|
7
7
|
* and be read as an option. No opener goes through a shell: Windows uses
|
|
8
8
|
* rundll32, because `cmd /c start` parses the URL again and treats its `&`
|
|
9
9
|
* as a command separator.
|
|
10
10
|
*/
|
|
11
|
-
export function openBrowser(url) {
|
|
11
|
+
export async function openBrowser(url) {
|
|
12
12
|
let href;
|
|
13
13
|
try {
|
|
14
14
|
const parsed = new URL(url);
|
|
@@ -24,12 +24,25 @@ export function openBrowser(url) {
|
|
|
24
24
|
: process.platform === 'win32'
|
|
25
25
|
? { bin: 'rundll32', args: ['url.dll,FileProtocolHandler', href] }
|
|
26
26
|
: { bin: 'xdg-open', args: [href] };
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
return startOpener(cmd.bin, cmd.args);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start `bin` detached. False if it couldn't be started. A missing program is
|
|
31
|
+
* reported as an 'error' event, not thrown, and with no listener it would
|
|
32
|
+
* crash the process, so this waits for 'spawn' or 'error' to know.
|
|
33
|
+
*/
|
|
34
|
+
export function startOpener(bin, args) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
try {
|
|
37
|
+
const child = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
|
38
|
+
child.once('spawn', () => {
|
|
39
|
+
child.unref();
|
|
40
|
+
resolve(true);
|
|
41
|
+
});
|
|
42
|
+
child.once('error', () => resolve(false));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
35
48
|
}
|
package/package.json
CHANGED