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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Top-level `defineCommand` in `src/index.ts` wires thirteen subcommands (`cloud`,

**`dcd mcp` (the bridge).** A second stdio entry point, `src/mcp/bridge.ts`, run by the `dcd mcp` command. It is a low-level `Server` that routes between two MCP clients: the `dcd_*` tools from the same in-process `createServer()` (over an in-memory transport), and the device tools proxied from the hosted `<api>/mcp`, so those are defined and contract-tested once, in the dcd API. It adds the inputs only a local process can serve: `install_app` takes `app_path` (uploaded with `uploadLocalApp`) and `run` takes `path` (a file is sent as `yaml`, a directory is uploaded as a flow bundle). Credentials are the CLI's (`getContext`): the hosted `/mcp` accepts an API key or a `dcd login` session with `x-dcd-org`. One connection is shared, and its `fetch` re-reads the auth headers from `getContext()` on every request, so a renewed session is picked up without remaking the connection (which would close it under a `run` still going). If the hosted endpoint can't be reached, it serves the `dcd_*` tools only and says why on stderr. Read-only mode keeps only device tools annotated `readOnlyHint`. `dcd-mcp` itself is unchanged.

**`dcd mcp install [client]`** (`src/utils/mcp-install.ts`) adds the server to Claude Code (`claude mcp add --scope user`), VS Code (`code --add-mcp`), or Claude Desktop / Cursor (merges an entry into their JSON config, and refuses a file that isn't plain JSON rather than rewrite it). Entries always start dcd by **full path**, since desktop apps opened from the Dock don't read the shell rc that puts `~/.dcd/bin` on PATH: the standalone binary is `process.execPath`; an npm install is node (`stableNodePath`, the PATH entry rather than Homebrew's versioned Cellar path) plus `argv[1]` as started, not resolved. It refuses to run from source or from the npx cache. Note citty runs a parent's `run` **after** its subcommand, so `mcpCommand.run` returns early when a positional is present — without that, `dcd mcp install` would go on to start a server.

## Contributing

Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release):
Expand Down
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,51 @@ There are three ways to connect:

`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:
**Set it up with `dcd mcp install`.** Install the CLI (see the top of this page), sign in, then add the server to your AI tool:

```sh-session
$ claude mcp add devicecloud -- npx -y @devicecloud.dev/dcd mcp
$ dcd login
$ dcd mcp install cursor # or claude-code, claude-desktop, vscode
```

Other clients:
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.

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

Claude Desktop (`claude_desktop_config.json`) and Cursor (`~/.cursor/mcp.json`):

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

VS Code (`.vscode/mcp.json`, or the user `mcp.json`) uses `servers` and a `type`:

```jsonc
{
"servers": {
"devicecloud": {
"type": "stdio",
"command": "/Users/you/.dcd/bin/dcd",
"args": ["mcp"]
}
}
}
```

If the CLI is installed globally, use `"command": "dcd", "args": ["mcp"]` instead.
Claude Code, run from a terminal, does see your PATH:

```sh-session
$ claude mcp add --scope user 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.

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.

Expand Down
3 changes: 3 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ if (-not $alreadyOnPath) {
Write-Host "Installed: $(& "$InstallDir\dcd.exe" --version)"
Write-Host ' Try: dcd --help'
}
Write-Host ''
Write-Host ' Connect an AI agent (Claude, Cursor, VS Code): dcd mcp install'
Write-Host " In an MCP config file, start dcd by its full path: $InstallDir\dcd.exe"

# --- warn about a conflicting (shadowing) install ---
# A leftover `npm install -g @devicecloud.dev/dcd` resolves earlier on PATH than
Expand Down
9 changes: 7 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,13 @@ main() {
;;
esac
info " Get started:"
info " ${CYAN}dcd login${RESET} ${DIM}sign in to DeviceCloud${RESET}"
info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}"
info " ${CYAN}dcd login${RESET} ${DIM}sign in to DeviceCloud${RESET}"
info " ${CYAN}dcd mcp install${RESET} ${DIM}connect an AI agent (Claude, Cursor, VS Code)${RESET}"
info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}"
info ""
# Desktop apps don't read the shell rc that puts dcd on PATH, so an MCP
# config written by hand needs the full path (dcd mcp install uses it).
info " ${DIM}In an MCP config file, start dcd by its full path: $INSTALL_DIR/dcd${RESET}"
info ""
}

Expand Down
193 changes: 191 additions & 2 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,200 @@
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';

import * as p from '@clack/prompts';
import { defineCommand } from 'citty';

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

import { jsonFlag } from '../config/flags/output.flags.js';
import { createBridgeServer } from '../mcp/bridge.js';
import { isReadOnly, logStderr } from '../mcp/context.js';
import { telemetry } from '../services/telemetry.service.js';
import { apiKeyOverride } from '../utils/auth.js';
import { canPrompt } from '../utils/ci.js';
import { CliError, getInstallMethod, logger } from '../utils/cli.js';
import { readConfig } from '../utils/config-store.js';
import {
CLIENT_LABELS,
claudeCodeAddArgs,
clientConfigPath,
MCP_CLIENTS,
type McpClient,
mcpServersSnippet,
SERVER_NAME,
serverLaunch,
type ServerLaunch,
shellLine,
stableNodePath,
vscodeAddArgs,
writeClientConfig,
} from '../utils/mcp-install.js';
import { colors } from '../utils/styling.js';
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.',
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.',
};

/** Run another tool's CLI without a shell. Undefined when it isn't installed. */
function runTool(command: string, args: string[]): { ok: boolean; output: string } | undefined {
const result = spawnSync(command, args, { encoding: 'utf8' });
if ((result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return undefined;
return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
}

async function pickClient(value: string | undefined): Promise<McpClient> {
if (value) {
if ((MCP_CLIENTS as readonly string[]).includes(value)) return value as McpClient;
throw new CliError(`Unknown client "${value}". Pick one of: ${MCP_CLIENTS.join(', ')}.`);
}

if (!canPrompt()) {
throw new CliError(`Say which client to set up: dcd mcp install <${MCP_CLIENTS.join('|')}>.`);
}

const picked = await p.select({
message: 'Which AI tool should use DeviceCloud?',
options: MCP_CLIENTS.map((c) => ({ value: c, label: CLIENT_LABELS[c] })),
});
if (p.isCancel(picked)) throw new CliError('Cancelled.');
return picked;
}

interface InstallResult {
/** The file written, or the command run, to add the server. */
target: string;
replaced: boolean;
}

/** Add the server to `client`, through its CLI or its JSON config file. */
function install(client: McpClient, launch: ServerLaunch): InstallResult {
if (client === 'claude-code') {
const add = claudeCodeAddArgs(launch);
const removed = runTool('claude', ['mcp', 'remove', '--scope', 'user', SERVER_NAME]);
if (!removed) {
throw new CliError(`The claude command isn't on PATH. Run this where it is:\n ${shellLine('claude', add)}`);
}

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

if (client === 'vscode') {
const add = vscodeAddArgs(launch);
const added = runTool('code', add);
if (!added) {
throw new CliError(
`The code command isn't on PATH (in VS Code: "Shell Command: Install 'code' command in PATH"). Or run:\n ${shellLine('code', add)}`,
);
}

if (!added.ok) throw new CliError(`code --add-mcp failed: ${added.output}`);
return { target: shellLine('code', add), replaced: false };
}

const file = clientConfigPath(client);
if (!file) {
throw new CliError(
`${CLIENT_LABELS[client]} has no known config file on ${process.platform}. Add this to it by hand:\n${JSON.stringify(mcpServersSnippet(launch), null, 2)}`,
);
}

return { target: file, ...writeClientConfig(file, launch) };
}

/** 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 === 'vscode') return { target: 'command', text: shellLine('code', vscodeAddArgs(launch)) };
return {
target: clientConfigPath(client) ?? 'your MCP config file',
text: JSON.stringify(mcpServersSnippet(launch), null, 2),
};
}

const installSub = defineCommand({
meta: {
name: 'install',
description: 'Add the DeviceCloud MCP server to Claude Code, Claude Desktop, Cursor or VS Code',
},
args: {
client: {
type: 'positional',
required: false,
description: `One of ${MCP_CLIENTS.join(', ')}. Asked when left out.`,
},
'read-only': {
type: 'boolean',
default: false,
description: 'Have the server offer only tools that change nothing',
},
print: {
type: 'boolean',
default: false,
description: 'Show what would be added, and change nothing',
},
json: jsonFlag,
},
async run({ args }) {
telemetry.setCommand('mcp install');
const client = await pickClient(args.client as string | undefined);
const installMethod = getInstallMethod();
const launch = serverLaunch({
installMethod,
execPath: installMethod === 'binary' ? process.execPath : stableNodePath(process.execPath),
// As started, not resolved: a global npm install's bin link keeps
// working across `npm install -g` upgrades.
scriptPath: process.argv[1] ? path.resolve(process.argv[1]) : undefined,
readOnly: Boolean(args['read-only']),
});

if (args.print) {
const shown = preview(client, launch);
if (args.json) {
logger.json({ status: 'SUCCESS', client, launch, target: shown.target, text: shown.text, written: false });
return;
}

logger.log(ui.section(`${CLIENT_LABELS[client]} config`));
logger.log(ui.branch(ui.fields([['add to', shown.target]])));
logger.log(shown.text);
return;
}

const result = install(client, launch);
const signedIn = Boolean(readConfig()?.session) || Boolean(apiKeyOverride());
if (args.json) {
logger.json({ status: 'SUCCESS', client, launch, target: result.target, replaced: result.replaced, written: true, signedIn });
return;
}

logger.log(
ui.success(`${result.replaced ? 'Updated' : 'Added'} DeviceCloud in ${CLIENT_LABELS[client]}`),
);
const rows: Field[] = [
[client === 'claude-code' || client === 'vscode' ? 'ran' : 'wrote', result.target],
['starts', colors.highlight(shellLine(launch.command, launch.args))],
];
logger.log(ui.branch(ui.fields(rows)));
logger.log(ui.info(RELOAD_HINT[client]));
if (!signedIn) {
logger.log(
ui.warn(`Not signed in. Run ${colors.highlight('dcd login')} so the server can use your account.`),
);
}
},
});

/**
* `dcd mcp`: the MCP server over stdio, for MCP clients to launch. It serves
* the dcd_* cloud-test tools and the device tools (see src/mcp/bridge.ts).
* Nothing but JSON-RPC may reach stdout.
* Nothing but JSON-RPC may reach stdout. `dcd mcp install` adds it to a
* client's config.
*/
export const mcpCommand = defineCommand({
meta: {
Expand All @@ -23,7 +208,11 @@ export const mcpCommand = defineCommand({
description: 'Serve only tools that change nothing (also DCD_MCP_READONLY=1)',
},
},
async run() {
subCommands: { install: installSub },
async run({ rawArgs }) {
// citty runs this after a subcommand too; `dcd mcp install` must not
// then start a server.
if (rawArgs.some((arg) => !arg.startsWith('-'))) return;
telemetry.setCommand('mcp');
const server = await createBridgeServer();
await server.connect(new StdioServerTransport());
Expand Down
Loading
Loading