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
56 changes: 50 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ See full documentation: [Docs](https://docs.devicecloud.dev)

## MCP server

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.
devicecloud.dev has an [MCP](https://modelcontextprotocol.io) server, so AI agents (Claude, Codex, 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.

There are three ways to connect:

Expand All @@ -54,10 +54,10 @@ There are three ways to connect:

```sh-session
$ dcd login
$ dcd mcp install cursor # or claude-code, claude-desktop, vscode
$ dcd mcp install cursor # or claude-code, claude-desktop, codex, vscode
```

Leave out the client name to pick from a list. Add `--read-only` for a server that only offers tools that change nothing, or `--print` to see what would be added without changing anything. For Claude Code and VS Code it runs `claude mcp add` and `code --add-mcp`. For Claude Desktop and Cursor it adds an entry to their JSON config file and keeps everything else in it. Restart the app afterwards.
Leave out the client name to pick from a list. Add `--read-only` for a server that only offers tools that change nothing, or `--print` to see what would be added without changing anything. For Claude Code, Codex and VS Code it runs `claude mcp add`, `codex mcp add` and `code --add-mcp`. For Claude Desktop and Cursor it adds an entry to their JSON config file and keeps everything else in it. Restart the app afterwards.

**Setting it up by hand.** Start dcd by its **full path**. Desktop apps such as Claude Desktop and Cursor are usually opened from the Dock or Start menu, so they don't see the PATH your shell sets up, and a bare `dcd` gives "command not found". The installer prints the path at the end; it's `~/.dcd/bin/dcd` by default, written out in full because config files don't expand `~`.

Expand Down Expand Up @@ -88,10 +88,19 @@ VS Code (`.vscode/mcp.json`, or the user `mcp.json`) uses `servers` and a `type`
}
```

Claude Code, run from a terminal, does see your PATH:
Codex (`~/.codex/config.toml`, shared by the Codex CLI, IDE extension and desktop app):

```toml
[mcp_servers.devicecloud]
command = "/Users/you/.dcd/bin/dcd"
args = ["mcp"]
```

Claude Code and the Codex CLI, run from a terminal, do see your PATH:

```sh-session
$ claude mcp add --scope user devicecloud -- dcd mcp
$ codex mcp add devicecloud -- dcd mcp
```

If you use the npm package instead of the standalone binary, the command is `npx` with `"args": ["-y", "@devicecloud.dev/dcd", "mcp"]`. That needs Node 22 or newer on the app's PATH, and it checks npm each time it starts.
Expand All @@ -102,12 +111,47 @@ It signs in the same way as the CLI: run `dcd login` once, or set `DEVICE_CLOUD_

### 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.
Point a client that speaks Streamable HTTP at `https://api.devicecloud.dev/mcp`. It signs you in through the browser (OAuth) the first time it connects, and you can disconnect it later under Settings → Account → Connected apps.

Claude Code (then run `/mcp` in Claude Code and choose `devicecloud` to sign in):

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

Cursor (`~/.cursor/mcp.json`):

```jsonc
{
"mcpServers": {
"devicecloud": { "url": "https://api.devicecloud.dev/mcp" }
}
}
```

VS Code (`.vscode/mcp.json`, or the user `mcp.json`), or `code --add-mcp '{"name":"devicecloud","type":"http","url":"https://api.devicecloud.dev/mcp"}'`:

```jsonc
{
"servers": {
"devicecloud": { "type": "http", "url": "https://api.devicecloud.dev/mcp" }
}
}
```

Codex:

```sh-session
$ codex mcp add devicecloud --url https://api.devicecloud.dev/mcp
$ codex mcp login devicecloud
```

Claude Desktop: open Settings → Connectors, choose **Add custom connector**, name it DeviceCloud and paste the URL. Its config file only takes local servers.

**Read-only.** `https://api.devicecloud.dev/mcp?read_only=true` serves only the device tools that change nothing (`list_devices`, `get_device_status`, `inspect_screen`, `take_screenshot`, `get_command_status`, `get_logs`, `get_human_status`, `cheat_sheet`). Quote it on a command line, since `?` is a wildcard in zsh: `claude mcp add --scope user --transport http devicecloud 'https://api.devicecloud.dev/mcp?read_only=true'`. Like `--read-only` locally, it keeps an agent from changing anything by mistake; it isn't a permission.

**API key instead of signing in.** For an agent that can't open a browser, such as one in CI, send an API key from the console (Settings → API Keys) as `Authorization: Bearer <your-api-key>`, for example with `claude mcp add ... --header "Authorization: Bearer <your-api-key>"`.

### `dcd-mcp`

The older server, with the `dcd_*` tools only. Add it to your MCP client config:
Expand Down
21 changes: 18 additions & 3 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
CLIENT_LABELS,
claudeCodeAddArgs,
clientConfigPath,
codexAddArgs,
MCP_CLIENTS,
type McpClient,
mcpServersSnippet,
Expand All @@ -35,6 +36,7 @@ import { type Field, ui } from '../utils/ui.js';
const RELOAD_HINT: Record<McpClient, string> = {
'claude-code': 'Start a new Claude Code session to load it.',
'claude-desktop': 'Restart Claude Desktop to load it.',
codex: 'Start a new Codex session to load it.',
cursor: 'Restart Cursor, or reload its MCP servers, to load it.',
vscode: 'VS Code asks you to trust the server the first time it starts.',
};
Expand Down Expand Up @@ -84,6 +86,18 @@ function install(client: McpClient, launch: ServerLaunch): InstallResult {
return { target: shellLine('claude', add), replaced: removed.ok };
}

if (client === 'codex') {
const add = codexAddArgs(launch);
const removed = runTool('codex', ['mcp', 'remove', SERVER_NAME]);
if (!removed) {
throw new CliError(`The codex command isn't on PATH. Run this where it is:\n ${shellLine('codex', add)}`);
}

const added = runTool('codex', add)!;
if (!added.ok) throw new CliError(`codex mcp add failed: ${added.output}`);
return { target: shellLine('codex', add), replaced: removed.ok };
}

if (client === 'vscode') {
const add = vscodeAddArgs(launch);
const added = runTool('code', add);
Expand All @@ -110,6 +124,7 @@ function install(client: McpClient, launch: ServerLaunch): InstallResult {
/** What `--print` shows: the command to run, or the file and the entry for it. */
function preview(client: McpClient, launch: ServerLaunch): { target: string; text: string } {
if (client === 'claude-code') return { target: 'command', text: shellLine('claude', claudeCodeAddArgs(launch)) };
if (client === 'codex') return { target: 'command', text: shellLine('codex', codexAddArgs(launch)) };
if (client === 'vscode') return { target: 'command', text: shellLine('code', vscodeAddArgs(launch)) };
return {
target: clientConfigPath(client) ?? 'your MCP config file',
Expand All @@ -120,7 +135,7 @@ function preview(client: McpClient, launch: ServerLaunch): { target: string; tex
const installSub = defineCommand({
meta: {
name: 'install',
description: 'Add the DeviceCloud MCP server to Claude Code, Claude Desktop, Cursor or VS Code',
description: 'Add the DeviceCloud MCP server to Claude Code, Claude Desktop, Codex, Cursor or VS Code',
},
args: {
client: {
Expand Down Expand Up @@ -177,7 +192,7 @@ const installSub = defineCommand({
ui.success(`${result.replaced ? 'Updated' : 'Added'} DeviceCloud in ${CLIENT_LABELS[client]}`),
);
const rows: Field[] = [
[client === 'claude-code' || client === 'vscode' ? 'ran' : 'wrote', result.target],
[client === 'claude-code' || client === 'codex' || client === 'vscode' ? 'ran' : 'wrote', result.target],
['starts', colors.highlight(shellLine(launch.command, launch.args))],
];
logger.log(ui.branch(ui.fields(rows)));
Expand All @@ -199,7 +214,7 @@ const installSub = defineCommand({
export const mcpCommand = defineCommand({
meta: {
name: 'mcp',
description: 'MCP server for AI agents, started by your MCP client (Claude, Cursor, VS Code)',
description: 'MCP server for AI agents, started by your MCP client (Claude, Codex, Cursor, VS Code)',
},
args: {
'read-only': {
Expand Down
6 changes: 5 additions & 1 deletion src/mcp/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,12 @@ class RemoteDevices {
private async open(): Promise<Client> {
const { apiUrl } = await getContext();
const client = new Client({ name: 'dcd-mcp-bridge', version: getCliVersion() });
// In read-only mode, ask the hosted server for its read-only tools too, so
// it refuses the rest as well. The filter here stays: an API without the
// flag ignores it, and a refused call still gets the read-only message.
const url = new URL(`${apiUrl}/mcp${isReadOnly() ? '?read_only=true' : ''}`);
await client.connect(
new StreamableHTTPClientTransport(new URL(`${apiUrl}/mcp`), {
new StreamableHTTPClientTransport(url, {
fetch: async (url, init) => {
const { auth } = await getContext();
const headers = new Headers(init?.headers);
Expand Down
15 changes: 13 additions & 2 deletions src/utils/mcp-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import { CliError, type InstallMethod } from './cli.js';
/** The server's name in every client's config. */
export const SERVER_NAME = 'devicecloud';

export const MCP_CLIENTS = ['claude-code', 'claude-desktop', 'cursor', 'vscode'] as const;
export const MCP_CLIENTS = ['claude-code', 'claude-desktop', 'codex', 'cursor', 'vscode'] as const;
export type McpClient = (typeof MCP_CLIENTS)[number];

export const CLIENT_LABELS: Record<McpClient, string> = {
'claude-code': 'Claude Code',
'claude-desktop': 'Claude Desktop',
codex: 'Codex',
cursor: 'Cursor',
vscode: 'VS Code',
};
Expand Down Expand Up @@ -92,7 +93,8 @@ export function stableNodePath(

/**
* The JSON config file a client reads, or undefined when it is configured
* through its own CLI (Claude Code, VS Code) or has no known location here.
* through its own CLI (Claude Code, Codex, VS Code) or has no known location
* here.
*/
export function clientConfigPath(
client: McpClient,
Expand Down Expand Up @@ -160,6 +162,15 @@ export function claudeCodeAddArgs(launch: ServerLaunch): string[] {
return ['mcp', 'add', '--scope', 'user', SERVER_NAME, '--', launch.command, ...launch.args];
}

/**
* `codex mcp add` arguments. Codex keeps MCP servers in ~/.codex/config.toml,
* which its CLI, IDE extension and desktop app share, so its own CLI writes
* the entry rather than this one parsing TOML.
*/
export function codexAddArgs(launch: ServerLaunch): string[] {
return ['mcp', 'add', SERVER_NAME, '--', launch.command, ...launch.args];
}

/** `code --add-mcp` arguments, which add the server to the VS Code user profile. */
export function vscodeAddArgs(launch: ServerLaunch): string[] {
return ['--add-mcp', JSON.stringify({ name: SERVER_NAME, ...launch })];
Expand Down
8 changes: 7 additions & 1 deletion test/unit/mcp-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ function startHosted(): Promise<{
failStart: boolean;
http: HttpServer;
inits: number;
paths: string[];
sessions: Record<string, unknown>[];
url: string;
}> {
const calls: { args: unknown; auth?: string; name: string }[] = [];
const hosted = { calls, failStart: false, inits: 0, sessions: [] as Record<string, unknown>[] };
const hosted = { calls, failStart: false, inits: 0, paths: [] as string[], sessions: [] as Record<string, unknown>[] };
const http = createHttpServer(async (req, res) => {
// The open sessions, as GET /device/sessions answers.
if (req.method === 'GET' && req.url === '/device/sessions') {
Expand All @@ -47,6 +48,7 @@ function startHosted(): Promise<{
return;
}

hosted.paths.push(req.url ?? '');
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
Expand Down Expand Up @@ -155,6 +157,7 @@ describe('dcd mcp bridge', () => {
const result = await client.callTool({ name: 'inspect_screen', arguments: {} });
expect(result.isError).to.not.equal(true);
expect(hosted.calls).to.deep.equal([{ name: 'inspect_screen', args: {}, auth: 'test-key' }]);
expect(new Set(hosted.paths)).to.deep.equal(new Set(['/mcp']));
await client.close();
});

Expand Down Expand Up @@ -247,6 +250,9 @@ describe('dcd mcp bridge', () => {
expect(hosted.calls).to.deep.equal([]);
await client.callTool({ name: 'inspect_screen', arguments: {} });
expect(hosted.calls.map((c) => c.name)).to.deep.equal(['inspect_screen']);
// The hosted server is asked for its read-only tools too.
expect(hosted.paths).to.not.be.empty;
expect(new Set(hosted.paths)).to.deep.equal(new Set(['/mcp?read_only=true']));
await client.close();
});

Expand Down
5 changes: 5 additions & 0 deletions test/unit/mcp-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CliError } from '../../src/utils/cli.js';
import {
claudeCodeAddArgs,
clientConfigPath,
codexAddArgs,
serverLaunch,
shellLine,
stableNodePath,
Expand Down Expand Up @@ -67,6 +68,7 @@ describe('dcd mcp install', () => {
);
expect(clientConfigPath('claude-desktop', 'linux', '/h')).to.equal(undefined);
expect(clientConfigPath('claude-code', 'darwin', '/h')).to.equal(undefined);
expect(clientConfigPath('codex', 'darwin', '/h')).to.equal(undefined);
});

describe('writeClientConfig', () => {
Expand Down Expand Up @@ -105,5 +107,8 @@ describe('dcd mcp install', () => {
expect(shellLine('claude', claudeCodeAddArgs(launch))).to.equal(
"claude mcp add --scope user devicecloud -- '/Users/a b/.dcd/bin/dcd' mcp --read-only",
);
expect(shellLine('codex', codexAddArgs(launch))).to.equal(
"codex mcp add devicecloud -- '/Users/a b/.dcd/bin/dcd' mcp --read-only",
);
});
});
Loading