Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,56 @@ See full documentation: [Docs](https://docs.devicecloud.dev)

## MCP server

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.
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.

Add it to your MCP client config:
There are three ways to connect:

| | Install | Tools | Use it when |
| --- | --- | --- | --- |
| **`dcd mcp`** (recommended) | the CLI | device tools + `dcd_*` cloud-test tools | you want everything, including installing a local build or running local flow files |
| **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 |
| **`dcd-mcp`** | the CLI | `dcd_*` cloud-test tools | you only run cloud tests (the older server; `dcd mcp` covers it) |

### `dcd mcp`

`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.

Claude Code:

```sh-session
$ claude mcp add devicecloud -- npx -y @devicecloud.dev/dcd mcp
```

Other clients:

```jsonc
{
"mcpServers": {
"devicecloud": {
"command": "npx",
"args": ["-y", "@devicecloud.dev/dcd", "mcp"]
}
}
}
```

If the CLI is installed globally, use `"command": "dcd", "args": ["mcp"]` instead.

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.

**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).

### Hosted

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.

```sh-session
$ claude mcp add --transport http devicecloud https://api.devicecloud.dev/mcp
```

### `dcd-mcp`

The older server, with the `dcd_*` tools only. Add it to your MCP client config:

```jsonc
{
Expand All @@ -56,7 +103,7 @@ Add it to your MCP client config:

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`.

**Tools**
**`dcd_*` tools** (served by both `dcd mcp` and `dcd-mcp`)

| Tool | What it does |
| --- | --- |
Expand Down
37 changes: 28 additions & 9 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,28 @@ export function platformOfModel(catalogue: DeviceCatalogue, model: string): 'and
throw new CliError(`"${model}" is a model on both iOS and Android. Pass --platform ios or --platform android.`);
}

/**
* The "Next steps" `dcd device start` prints. They follow the session: a
* device that isn't ready yet can't be inspected, so that comes first, and
* a session with no app gets the install step.
*/
export function startNextSteps(session: DeviceSession, hasApp: boolean): [string, string][] {
const { id } = session;
const steps: [string, string][] = [];
if (!USABLE_STATES.has(session.state)) {
steps.push(['check it is ready', `dcd device status ${id}`]);
}

if (!hasApp) steps.push(['install an app', `dcd device install ${id} <app>`]);
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'],
);
return steps;
}

const startSub = defineCommand({
meta: { name: 'start', description: 'Start a device session' },
args: {
Expand Down Expand Up @@ -373,15 +395,12 @@ const startSub = defineCommand({
logger.log(ui.section('Device session'));
printSession(session);
logger.log(ui.section('Next steps'));
logger.log(
ui.branch(
ui.fields([
['inspect the screen', colors.highlight(`dcd device inspect ${session.id}`)],
['run a flow', colors.highlight(`dcd device run ${session.id} flow.yaml`)],
['stop', colors.highlight(`dcd device stop ${session.id}`)],
]),
),
);
const hasApp = Boolean(installedId ?? uploadedId);
const steps = startNextSteps(session, hasApp);
logger.log(ui.branch(ui.fields(steps.map(([label, cmd]) => [label, colors.highlight(cmd)]))));
if (!USABLE_STATES.has(session.state)) {
logger.log(ui.info('Next time, `dcd device start --wait` waits until the device is ready.'));
}
},
};
});
Expand Down
2 changes: 1 addition & 1 deletion src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { telemetry } from '../services/telemetry.service.js';
export const mcpCommand = defineCommand({
meta: {
name: 'mcp',
description: 'Run the DeviceCloud MCP server on stdio (cloud tests and cloud devices)',
description: 'MCP server for AI agents, started by your MCP client (Claude, Cursor, VS Code)',
},
args: {
'read-only': {
Expand Down
20 changes: 20 additions & 0 deletions test/unit/device-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deviceCommand,
newLogLines,
parseEnvFlags,
startNextSteps,
} from '../../src/commands/device.js';
import { DeviceApiError } from '../../src/gateways/device-gateway.js';
import type { DeviceSession } from '../../src/types/domain/device-session.types.js';
Expand Down Expand Up @@ -730,6 +731,25 @@ describe('dcd device', () => {
});

describe('helpers', () => {
it('startNextSteps fits the session: check readiness first, install only with no app', () => {
const cmds = (s: DeviceSession, hasApp: boolean) => startNextSteps(s, hasApp).map(([, cmd]) => cmd);

expect(cmds(session('a', { state: 'queued' }), false)).to.deep.equal([
'dcd device status a',
'dcd device install a <app>',
'dcd device inspect a',
'dcd device run a flow.yaml',
'dcd device stop a',
'dcd device --help',
]);
expect(cmds(session('a', { state: 'ready' }), true)).to.deep.equal([
'dcd device inspect a',
'dcd device run a flow.yaml',
'dcd device stop a',
'dcd device --help',
]);
});

it('formatWhen says how far off a time is', () => {
const now = Date.parse('2026-10-19T10:00:00Z');
expect(formatWhen('2026-10-19T10:12:00Z', now)).to.match(/^in 12m \(.+\)$/);
Expand Down
Loading