From 5173a838af8dabc44b579815a12951529c28ac1e Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Mon, 9 Nov 2026 16:38:42 +0000 Subject: [PATCH] feat(mcp): set up Codex with dcd mcp install, and document every hosted client - dcd mcp install codex runs codex mcp add (after codex mcp remove, so a rerun replaces the entry). Codex keeps servers in ~/.codex/config.toml, shared by its CLI, IDE extension and desktop app, so its own CLI writes the entry. --print shows the command. - In read-only mode, dcd mcp asks the hosted server for its read-only tools too (/mcp?read_only=true). The local filter stays, so an API without the flag is still read-only here. - README: the hosted server's setup for Claude Code, Cursor, VS Code, Codex and Claude Desktop, its read-only URL (quoted, since ? is a zsh glob), API-key sign-in, and Codex for dcd mcp. --- README.md | 56 +++++++++++++++++++++++++++++++---- src/commands/mcp.ts | 21 +++++++++++-- src/mcp/bridge.ts | 6 +++- src/utils/mcp-install.ts | 15 ++++++++-- test/unit/mcp-bridge.test.ts | 8 ++++- test/unit/mcp-install.test.ts | 5 ++++ 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ddb1fcb..9df9430 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 `~`. @@ -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. @@ -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 `, for example with `claude mcp add ... --header "Authorization: Bearer "`. + ### `dcd-mcp` The older server, with the `dcd_*` tools only. Add it to your MCP client config: diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index be7cb71..5680254 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -18,6 +18,7 @@ import { CLIENT_LABELS, claudeCodeAddArgs, clientConfigPath, + codexAddArgs, MCP_CLIENTS, type McpClient, mcpServersSnippet, @@ -35,6 +36,7 @@ import { type Field, ui } from '../utils/ui.js'; const RELOAD_HINT: Record = { '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.', }; @@ -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); @@ -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', @@ -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: { @@ -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))); @@ -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': { diff --git a/src/mcp/bridge.ts b/src/mcp/bridge.ts index 91ee0f5..be2d395 100644 --- a/src/mcp/bridge.ts +++ b/src/mcp/bridge.ts @@ -221,8 +221,12 @@ class RemoteDevices { private async open(): Promise { 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); diff --git a/src/utils/mcp-install.ts b/src/utils/mcp-install.ts index 5d5faf0..a33d006 100644 --- a/src/utils/mcp-install.ts +++ b/src/utils/mcp-install.ts @@ -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 = { 'claude-code': 'Claude Code', 'claude-desktop': 'Claude Desktop', + codex: 'Codex', cursor: 'Cursor', vscode: 'VS Code', }; @@ -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, @@ -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 })]; diff --git a/test/unit/mcp-bridge.test.ts b/test/unit/mcp-bridge.test.ts index ead46a9..14c5b69 100644 --- a/test/unit/mcp-bridge.test.ts +++ b/test/unit/mcp-bridge.test.ts @@ -29,11 +29,12 @@ function startHosted(): Promise<{ failStart: boolean; http: HttpServer; inits: number; + paths: string[]; sessions: Record[]; url: string; }> { const calls: { args: unknown; auth?: string; name: string }[] = []; - const hosted = { calls, failStart: false, inits: 0, sessions: [] as Record[] }; + const hosted = { calls, failStart: false, inits: 0, paths: [] as string[], sessions: [] as Record[] }; const http = createHttpServer(async (req, res) => { // The open sessions, as GET /device/sessions answers. if (req.method === 'GET' && req.url === '/device/sessions') { @@ -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() || '{}'); @@ -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(); }); @@ -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(); }); diff --git a/test/unit/mcp-install.test.ts b/test/unit/mcp-install.test.ts index 05c133b..b945943 100644 --- a/test/unit/mcp-install.test.ts +++ b/test/unit/mcp-install.test.ts @@ -7,6 +7,7 @@ import { CliError } from '../../src/utils/cli.js'; import { claudeCodeAddArgs, clientConfigPath, + codexAddArgs, serverLaunch, shellLine, stableNodePath, @@ -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', () => { @@ -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", + ); }); });