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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Top-level `defineCommand` in `src/index.ts` wires thirteen subcommands (`cloud`,

**MCP server.** `src/mcp/` is a third front-end onto the same service layer (a sibling to `src/commands/`), shipped as the `dcd-mcp` bin over stdio transport (`@modelcontextprotocol/sdk`, `zod` schemas). `index.ts` boots the server; `server.ts` registers tools; `context.ts` resolves auth + API URL **lazily**, and again when a `dcd login` session nears expiry (so `tools/list` works unauthenticated, auth errors surface as tool errors rather than a boot crash, and a long-lived server outlasts the one-hour access token) via the same `resolveAuth`/`resolveApiUrl` as the CLI — `DEVICE_CLOUD_API_KEY` env or stored `dcd login` session, with `DCD_API_URL` to override. **Critical invariant: stdout is the JSON-RPC channel** — tools must never call the `src/commands/*` layer or `utils/cli` `logger` (both write to stdout / can `process.exit`); they call services/gateways directly with `logStderr` and return data via `helpers.ts` `jsonResult`/`errorResult`. The `runTool` wrapper records `mcp tool …` telemetry and converts thrown errors to `isError` results. Tools: `dcd_list_devices`, `dcd_list_runs`, `dcd_get_status`, `dcd_download_artifacts` (read-only), and `dcd_run_cloud_test` (billable — gated out by `--read-only` / `DCD_MCP_READONLY=1`, annotated destructive, async-by-default). `dcd_run_cloud_test` reuses `computeCommonRoot`/`buildTestMetadataMap` from `src/services/flow-paths.ts` (extracted from `cloud.ts` so both build identical server-side paths). Registry manifest: `server.json` at repo root.

**`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`, and the remote connection is remade when a session is renewed. 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` (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.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,7 @@ const openSub = defineCommand({
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined);
const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id);
// Only a person at a terminal gets a browser; a script gets the URL.
const opened = interactive(json) && openBrowser(session.monitor_url);
const opened = interactive(json) && (await openBrowser(session.monitor_url));
return {
session,
data: { url: session.monitor_url, opened },
Expand Down
2 changes: 1 addition & 1 deletion src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const loginCommand = defineCommand({
if (noBrowser) {
rows.push(ui.note('Open this URL in a browser to finish login:'), colors.highlight(loginUrl));
} else {
const opened = openBrowser(loginUrl);
const opened = await openBrowser(loginUrl);
rows.push(
ui.note(
opened
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* a bundle).
*
* Auth is the CLI's: an API key or a `dcd login` session (getContext). The
* hosted endpoint accepts both. A session is renewed before it expires, and
* the remote connection is remade with it.
* hosted endpoint accepts both. A session is renewed before it expires; the
* one shared connection re-reads the auth on every request (RemoteDevices).
*
* stdout is the JSON-RPC channel: everything else goes to stderr.
*/
Expand Down
32 changes: 23 additions & 9 deletions src/utils/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { spawn } from 'node:child_process';

/**
* Open `url` in the default browser. False if it isn't an http(s) URL or no
* opener could be started.
* opener could be started (none installed, as on a headless box).
*
* Only http(s) is opened, so a URL can't name a local file or start with `-`
* and be read as an option. No opener goes through a shell: Windows uses
* rundll32, because `cmd /c start` parses the URL again and treats its `&`
* as a command separator.
*/
export function openBrowser(url: string): boolean {
export async function openBrowser(url: string): Promise<boolean> {
let href: string;
try {
const parsed = new URL(url);
Expand All @@ -25,11 +25,25 @@ export function openBrowser(url: string): boolean {
: process.platform === 'win32'
? { bin: 'rundll32', args: ['url.dll,FileProtocolHandler', href] }
: { bin: 'xdg-open', args: [href] };
try {
const child = spawn(cmd.bin, cmd.args, { detached: true, stdio: 'ignore' });
child.unref();
return true;
} catch {
return false;
}
return startOpener(cmd.bin, cmd.args);
}

/**
* Start `bin` detached. False if it couldn't be started. A missing program is
* reported as an 'error' event, not thrown, and with no listener it would
* crash the process, so this waits for 'spawn' or 'error' to know.
*/
export function startOpener(bin: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
try {
const child = spawn(bin, args, { detached: true, stdio: 'ignore' });
child.once('spawn', () => {
child.unref();
resolve(true);
});
child.once('error', () => resolve(false));
} catch {
resolve(false);
}
});
}
15 changes: 10 additions & 5 deletions test/unit/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { expect } from 'chai';

import { openBrowser } from '../../src/utils/browser.js';
import { openBrowser, startOpener } from '../../src/utils/browser.js';

describe('openBrowser', () => {
it('opens nothing that is not an http(s) URL', () => {
expect(openBrowser('file:///etc/passwd')).to.equal(false);
expect(openBrowser('--help')).to.equal(false);
expect(openBrowser('not a url')).to.equal(false);
it('opens nothing that is not an http(s) URL', async () => {
expect(await openBrowser('file:///etc/passwd')).to.equal(false);
expect(await openBrowser('--help')).to.equal(false);
expect(await openBrowser('not a url')).to.equal(false);
});

it('returns false, rather than crashing, when the opener is not installed', async () => {
// As with xdg-open on a headless box: spawn emits ENOENT as an event.
expect(await startOpener('dcd-test-no-such-opener', ['https://example.com'])).to.equal(false);
});
});
Loading