@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 CHANGED
@@ -36,9 +36,56 @@ See full documentation: [Docs](https://docs.devicecloud.dev)
36
36
 
37
37
  ## MCP server
38
38
 
39
- The same npm package ships an [MCP](https://modelcontextprotocol.io) server (`dcd-mcp` bin) so AI agents — Claude, Cursor, VS Code — can drive devicecloud.dev directly.
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
- Add it to your MCP client config:
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
- **Tools**
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. */
@@ -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
- logger.log(ui.branch(ui.fields([
293
- ['inspect the screen', colors.highlight(`dcd device inspect ${session.id}`)],
294
- ['run a flow', colors.highlight(`dcd device run ${session.id} flow.yaml`)],
295
- ['stop', colors.highlight(`dcd device stop ${session.id}`)],
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 },
@@ -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:'));
@@ -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: 'Run the DeviceCloud MCP server on stdio (cloud tests and cloud devices)',
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': {
@@ -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, and
14
- * the remote connection is remade with it.
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
  */
@@ -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>;
@@ -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
- try {
28
- const child = spawn(cmd.bin, cmd.args, { detached: true, stdio: 'ignore' });
29
- child.unref();
30
- return true;
31
- }
32
- catch {
33
- return false;
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
@@ -68,7 +68,7 @@
68
68
  "test:unit": "node scripts/test-runner.mjs --unit",
69
69
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
70
70
  },
71
- "version": "5.11.0-beta.2",
71
+ "version": "5.11.0-beta.3",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },