diff --git a/README.md b/README.md index 04b861f..b5a4cc5 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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 | | --- | --- | diff --git a/src/commands/device.ts b/src/commands/device.ts index 3f63694..1e7f265 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -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} `]); + 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: { @@ -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.')); + } }, }; }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d6c797c..3b9dd56 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -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': { diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index af7d334..62d4869 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -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'; @@ -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 ', + '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 \(.+\)$/);