@devicecloud.dev/dcd 5.10.0 → 5.11.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,4 @@
1
+ /** Printed to stderr by every `dcd live` command. */
2
+ export declare const LIVE_DEPRECATION: string;
1
3
  export declare const liveCommand: import("citty").CommandDef<import("citty").ArgsDef>;
2
4
  export default liveCommand;
@@ -10,6 +10,8 @@ import { resolveApiUrl } from '../utils/config-store.js';
10
10
  import { colors, formatUrl } from '../utils/styling.js';
11
11
  import { ui } from '../utils/ui.js';
12
12
  const PLATFORM_OPTIONS = ['android', 'ios'];
13
+ /** Printed to stderr by every `dcd live` command. */
14
+ export const LIVE_DEPRECATION = ui.deprecation('`dcd live` is deprecated and will be removed. Use `dcd device` instead: see `dcd device --help`.');
13
15
  const READY_TIMEOUT_MS = 180_000;
14
16
  const READY_POLL_MS = 2_000;
15
17
  async function requireAuth(keyFlag) {
@@ -591,8 +593,15 @@ export const liveCommand = defineCommand({
591
593
  meta: {
592
594
  name: 'live',
593
595
  description: 'Start and interact with a live device session',
596
+ // Replaced by `dcd device` (Agent Devices plan 3.4). Kept working for
597
+ // existing scripts, but no longer listed in `dcd --help`.
598
+ hidden: true,
594
599
  },
595
600
  subCommands,
601
+ // Runs before any subcommand. On stderr, so --json output is unchanged.
602
+ setup() {
603
+ process.stderr.write(`${LIVE_DEPRECATION}\n`);
604
+ },
596
605
  // citty's runCommand does not early-return after dispatching to a
597
606
  // subcommand — it still invokes the parent `run` afterwards. So when a
598
607
  // subcommand was matched, bail out here; otherwise the menu would print
@@ -33,11 +33,11 @@
33
33
  */
34
34
  import * as p from '@clack/prompts';
35
35
  import { defineCommand } from 'citty';
36
- import { spawn } from 'node:child_process';
37
36
  import { createHash, randomBytes } from 'node:crypto';
38
37
  import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl, } from '../config/environments.js';
39
38
  import { getSupabaseKey } from '../config/supabase-key.js';
40
39
  import { jsonFlag } from '../config/flags/output.flags.js';
40
+ import { openBrowser } from '../utils/browser.js';
41
41
  import { canPrompt } from '../utils/ci.js';
42
42
  import { CliError, logger } from '../utils/cli.js';
43
43
  import { readConfig, writeConfig } from '../utils/config-store.js';
@@ -235,21 +235,6 @@ export const loginCommand = defineCommand({
235
235
  }
236
236
  },
237
237
  });
238
- function openBrowser(url) {
239
- const cmd = process.platform === 'darwin'
240
- ? { bin: 'open', args: [url] }
241
- : process.platform === 'win32'
242
- ? { bin: 'cmd', args: ['/c', 'start', '""', url] }
243
- : { bin: 'xdg-open', args: [url] };
244
- try {
245
- const child = spawn(cmd.bin, cmd.args, { detached: true, stdio: 'ignore' });
246
- child.unref();
247
- return true;
248
- }
249
- catch {
250
- return false;
251
- }
252
- }
253
238
  async function pollForClaim(apiUrl, state, codeVerifier) {
254
239
  const claimUrl = `${apiUrl}/cli-login/claim`;
255
240
  const deadline = Date.now() + LOGIN_TIMEOUT_MS;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `dcd mcp`: the MCP server over stdio, for MCP clients to launch. It serves
3
+ * the dcd_* cloud-test tools and the device tools (see src/mcp/bridge.ts).
4
+ * Nothing but JSON-RPC may reach stdout.
5
+ */
6
+ export declare const mcpCommand: import("citty").CommandDef<{
7
+ readonly 'read-only': {
8
+ readonly type: "boolean";
9
+ readonly default: false;
10
+ readonly description: "Serve only tools that change nothing (also DCD_MCP_READONLY=1)";
11
+ };
12
+ }>;
@@ -0,0 +1,29 @@
1
+ import { defineCommand } from 'citty';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createBridgeServer } from '../mcp/bridge.js';
4
+ import { isReadOnly, logStderr } from '../mcp/context.js';
5
+ import { telemetry } from '../services/telemetry.service.js';
6
+ /**
7
+ * `dcd mcp`: the MCP server over stdio, for MCP clients to launch. It serves
8
+ * the dcd_* cloud-test tools and the device tools (see src/mcp/bridge.ts).
9
+ * Nothing but JSON-RPC may reach stdout.
10
+ */
11
+ export const mcpCommand = defineCommand({
12
+ meta: {
13
+ name: 'mcp',
14
+ description: 'Run the DeviceCloud MCP server on stdio (cloud tests and cloud devices)',
15
+ },
16
+ args: {
17
+ 'read-only': {
18
+ type: 'boolean',
19
+ default: false,
20
+ description: 'Serve only tools that change nothing (also DCD_MCP_READONLY=1)',
21
+ },
22
+ },
23
+ async run() {
24
+ telemetry.setCommand('mcp');
25
+ const server = await createBridgeServer();
26
+ await server.connect(new StdioServerTransport());
27
+ logStderr(`DeviceCloud MCP server running on stdio${isReadOnly() ? ' (read-only)' : ''}`);
28
+ },
29
+ });
@@ -0,0 +1,88 @@
1
+ import type { AuthContext } from '../types/domain/auth.types.js';
2
+ import type { DeviceArtifactsPage, DeviceBundleUploadUrls, DeviceFlowBundle, DeviceCatalogue, DeviceCommandResult, DeviceHumanStatus, DeviceInstalledApp, DeviceLogs, DeviceRunResponse, DeviceScreen, DeviceScreenshot, DeviceSession, StartDeviceSessionRequest } from '../types/domain/device-session.types.js';
3
+ /**
4
+ * An error answer from /device/sessions. Unlike ApiError it keeps the API's
5
+ * machine code (`LIVE_NOT_FOUND`, `LIVE_SPEND_CAP`, …) and the `session`
6
+ * block some errors carry, because `dcd device` maps the code to an exit code
7
+ * and prints the session in its JSON envelope.
8
+ */
9
+ export declare class DeviceApiError extends Error {
10
+ code: null | string;
11
+ session: DeviceSession | null;
12
+ status: number;
13
+ constructor(message: string, status: number, code: null | string, session: DeviceSession | null);
14
+ }
15
+ /** The Agent Devices session API (dcd docs/device-v1-plan.md, section 4.1). */
16
+ export declare const DeviceGateway: {
17
+ artifacts(baseUrl: string, auth: AuthContext, id: string, before?: string): Promise<DeviceArtifactsPage>;
18
+ bundleUploadUrls(baseUrl: string, auth: AuthContext, bytes: number): Promise<DeviceBundleUploadUrls>;
19
+ command(baseUrl: string, auth: AuthContext, id: string, commandId: string): Promise<DeviceRunResponse>;
20
+ /** Whether the session waits for a person, or was handed back. */
21
+ human(baseUrl: string, auth: AuthContext, id: string): Promise<{
22
+ human: DeviceHumanStatus;
23
+ session: DeviceSession;
24
+ }>;
25
+ inspect(baseUrl: string, auth: AuthContext, id: string): Promise<{
26
+ screen: DeviceScreen;
27
+ session: DeviceSession;
28
+ }>;
29
+ install(baseUrl: string, auth: AuthContext, id: string, app: {
30
+ binaryUploadId: string;
31
+ } | {
32
+ url: string;
33
+ }): Promise<{
34
+ app: DeviceInstalledApp;
35
+ session: DeviceSession;
36
+ }>;
37
+ launch(baseUrl: string, auth: AuthContext, id: string, body: {
38
+ appId?: string;
39
+ clearState?: boolean;
40
+ }): Promise<{
41
+ result: DeviceCommandResult;
42
+ session: DeviceSession;
43
+ }>;
44
+ list(baseUrl: string, auth: AuthContext, all?: boolean): Promise<{
45
+ sessions: DeviceSession[];
46
+ }>;
47
+ logs(baseUrl: string, auth: AuthContext, id: string, lines: number): Promise<{
48
+ logs: DeviceLogs;
49
+ session: DeviceSession;
50
+ }>;
51
+ models(baseUrl: string, auth: AuthContext): Promise<DeviceCatalogue>;
52
+ registerBundle(baseUrl: string, auth: AuthContext, body: {
53
+ backblaze: boolean;
54
+ bytes: number;
55
+ enc?: unknown;
56
+ id: string;
57
+ sha: string;
58
+ supabase: boolean;
59
+ }): Promise<{
60
+ bundle: DeviceFlowBundle;
61
+ }>;
62
+ /** Ask a person for help: the session moves to needs_human until they hand it back. */
63
+ requestHuman(baseUrl: string, auth: AuthContext, id: string, reason: string): Promise<{
64
+ human: DeviceHumanStatus;
65
+ session: DeviceSession;
66
+ }>;
67
+ run(baseUrl: string, auth: AuthContext, id: string, body: {
68
+ env?: Record<string, string>;
69
+ flow?: string;
70
+ flowUploadId: string;
71
+ } | {
72
+ env?: Record<string, string>;
73
+ yaml: string;
74
+ }): Promise<DeviceRunResponse>;
75
+ screenshot(baseUrl: string, auth: AuthContext, id: string): Promise<{
76
+ screenshot: DeviceScreenshot;
77
+ session: DeviceSession;
78
+ }>;
79
+ start(baseUrl: string, auth: AuthContext, body: StartDeviceSessionRequest): Promise<{
80
+ session: DeviceSession;
81
+ }>;
82
+ status(baseUrl: string, auth: AuthContext, id: string): Promise<{
83
+ session: DeviceSession;
84
+ }>;
85
+ stop(baseUrl: string, auth: AuthContext, id: string): Promise<{
86
+ session: DeviceSession;
87
+ }>;
88
+ };
@@ -0,0 +1,140 @@
1
+ import { ApiGateway } from './api-gateway.js';
2
+ /**
3
+ * An error answer from /device/sessions. Unlike ApiError it keeps the API's
4
+ * machine code (`LIVE_NOT_FOUND`, `LIVE_SPEND_CAP`, …) and the `session`
5
+ * block some errors carry, because `dcd device` maps the code to an exit code
6
+ * and prints the session in its JSON envelope.
7
+ */
8
+ export class DeviceApiError extends Error {
9
+ code;
10
+ session;
11
+ status;
12
+ constructor(message, status, code, session) {
13
+ super(message);
14
+ this.name = 'DeviceApiError';
15
+ this.status = status;
16
+ this.code = code;
17
+ this.session = session;
18
+ }
19
+ }
20
+ /**
21
+ * The API's error envelope: `{ statusCode, message, error? }`, where `error`
22
+ * is either Nest's status text or a machine code such as LIVE_NOT_FOUND.
23
+ * Only an upper-case code is kept, so "Not Found" never reads as one.
24
+ */
25
+ async function toDeviceApiError(res, path) {
26
+ const text = await res.text();
27
+ let body = {};
28
+ try {
29
+ body = JSON.parse(text);
30
+ }
31
+ catch {
32
+ // Not JSON: a proxy page or plain text. The text is the message.
33
+ }
34
+ const raw = Array.isArray(body.message) ? body.message.join('; ') : body.message;
35
+ let message = typeof raw === 'string' && raw ? raw : text.trim() || `HTTP ${res.status}`;
36
+ if (res.status === 401) {
37
+ message =
38
+ 'Authentication failed — your credentials are invalid or expired. ' +
39
+ `Re-run \`dcd login\` or check your API key. (${message})`;
40
+ }
41
+ const code = typeof body.error === 'string' && /^[A-Z][A-Z0-9_]+$/.test(body.error) ? body.error : null;
42
+ if (code === 'LIVE_HUMAN_IN_CONTROL') {
43
+ // The API's text points at MCP and raw HTTP too; a CLI user needs the command.
44
+ const id = /^\/device\/sessions\/([^/?]+)/.exec(path)?.[1];
45
+ const name = id ? decodeURIComponent(id) : '<id>';
46
+ message =
47
+ `A person has taken control of ${name} in the console, so it takes no commands until they hand it back. ` +
48
+ `Wait for that with \`dcd device human ${name} --wait\`.`;
49
+ }
50
+ const session = body.session && typeof body.session === 'object' ? body.session : null;
51
+ return new DeviceApiError(message, res.status, code, session);
52
+ }
53
+ async function request(baseUrl, auth, method, path, body) {
54
+ const url = `${baseUrl}${path}`;
55
+ let res;
56
+ try {
57
+ res = await fetch(url, {
58
+ method,
59
+ headers: {
60
+ ...auth.headers,
61
+ // Recorded as the command's source in the session timeline.
62
+ 'x-dcd-source': 'cli',
63
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
64
+ },
65
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
66
+ });
67
+ }
68
+ catch (error) {
69
+ if (error instanceof TypeError && error.message === 'fetch failed') {
70
+ throw ApiGateway.enhanceFetchError(error, url);
71
+ }
72
+ throw error;
73
+ }
74
+ if (!res.ok)
75
+ throw await toDeviceApiError(res, path);
76
+ try {
77
+ return (await res.json());
78
+ }
79
+ catch (error) {
80
+ throw new Error(`${method} ${path}: the API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`, { cause: error });
81
+ }
82
+ }
83
+ const sessionPath = (id) => `/device/sessions/${encodeURIComponent(id)}`;
84
+ /** The Agent Devices session API (dcd docs/device-v1-plan.md, section 4.1). */
85
+ export const DeviceGateway = {
86
+ artifacts(baseUrl, auth, id, before) {
87
+ const query = new URLSearchParams({ limit: '200', ...(before && { before }) });
88
+ return request(baseUrl, auth, 'GET', `${sessionPath(id)}/artifacts?${query}`);
89
+ },
90
+ bundleUploadUrls(baseUrl, auth, bytes) {
91
+ return request(baseUrl, auth, 'POST', '/device/bundles/upload-url', { bytes });
92
+ },
93
+ command(baseUrl, auth, id, commandId) {
94
+ return request(baseUrl, auth, 'GET', `${sessionPath(id)}/commands/${encodeURIComponent(commandId)}`);
95
+ },
96
+ /** Whether the session waits for a person, or was handed back. */
97
+ human(baseUrl, auth, id) {
98
+ return request(baseUrl, auth, 'GET', `${sessionPath(id)}/human`);
99
+ },
100
+ inspect(baseUrl, auth, id) {
101
+ return request(baseUrl, auth, 'GET', `${sessionPath(id)}/inspect`);
102
+ },
103
+ install(baseUrl, auth, id, app) {
104
+ return request(baseUrl, auth, 'POST', `${sessionPath(id)}/install`, app);
105
+ },
106
+ launch(baseUrl, auth, id, body) {
107
+ return request(baseUrl, auth, 'POST', `${sessionPath(id)}/launch`, body);
108
+ },
109
+ list(baseUrl, auth, all = false) {
110
+ return request(baseUrl, auth, 'GET', `/device/sessions${all ? '?all=true' : ''}`);
111
+ },
112
+ logs(baseUrl, auth, id, lines) {
113
+ return request(baseUrl, auth, 'GET', `${sessionPath(id)}/logs?lines=${lines}`);
114
+ },
115
+ models(baseUrl, auth) {
116
+ return request(baseUrl, auth, 'GET', '/device/models');
117
+ },
118
+ registerBundle(baseUrl, auth, body) {
119
+ return request(baseUrl, auth, 'POST', '/device/bundles', body);
120
+ },
121
+ /** Ask a person for help: the session moves to needs_human until they hand it back. */
122
+ requestHuman(baseUrl, auth, id, reason) {
123
+ return request(baseUrl, auth, 'POST', `${sessionPath(id)}/request-human`, { reason });
124
+ },
125
+ run(baseUrl, auth, id, body) {
126
+ return request(baseUrl, auth, 'POST', `${sessionPath(id)}/run`, body);
127
+ },
128
+ screenshot(baseUrl, auth, id) {
129
+ return request(baseUrl, auth, 'GET', `${sessionPath(id)}/screenshot`);
130
+ },
131
+ start(baseUrl, auth, body) {
132
+ return request(baseUrl, auth, 'POST', '/device/sessions', body);
133
+ },
134
+ status(baseUrl, auth, id) {
135
+ return request(baseUrl, auth, 'GET', sessionPath(id));
136
+ },
137
+ stop(baseUrl, auth, id) {
138
+ return request(baseUrl, auth, 'DELETE', sessionPath(id));
139
+ },
140
+ };
package/dist/index.js CHANGED
@@ -3,10 +3,12 @@ import { updateSettings } from '@clack/prompts';
3
3
  import { defineCommand, runCommand, showUsage } from 'citty';
4
4
  import { artifactsCommand } from './commands/artifacts.js';
5
5
  import { cloudCommand } from './commands/cloud.js';
6
+ import { deviceCommand } from './commands/device.js';
6
7
  import { listCommand } from './commands/list.js';
7
8
  import { liveCommand } from './commands/live.js';
8
9
  import { loginCommand } from './commands/login.js';
9
10
  import { logoutCommand } from './commands/logout.js';
11
+ import { mcpCommand } from './commands/mcp.js';
10
12
  import { statusCommand } from './commands/status.js';
11
13
  import { switchOrgCommand } from './commands/switch-org.js';
12
14
  import { upgradeCommand } from './commands/upgrade.js';
@@ -30,7 +32,9 @@ const main = defineCommand({
30
32
  list: listCommand,
31
33
  status: statusCommand,
32
34
  artifacts: artifactsCommand,
35
+ device: deviceCommand,
33
36
  live: liveCommand,
37
+ mcp: mcpCommand,
34
38
  login: loginCommand,
35
39
  logout: logoutCommand,
36
40
  whoami: whoamiCommand,
@@ -0,0 +1,27 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { type Tool } from '@modelcontextprotocol/sdk/types.js';
3
+ import type { AuthContext } from '../types/domain/auth.types.js';
4
+ /**
5
+ * How long one forwarded call may take. Longer than dcd_run_cloud_test's
6
+ * longest wait (3600s) plus an upload. The MCP client calling this server
7
+ * still applies its own timeout, and its cancel is passed through.
8
+ */
9
+ export declare const CALL_TIMEOUT_MS: number;
10
+ /** A hosted tool with this bridge's local inputs added. */
11
+ export declare function withLocalInputs(tool: Tool): Tool;
12
+ /**
13
+ * Do the local part of a call (uploads, reading a file) and return the
14
+ * arguments the hosted tool takes.
15
+ */
16
+ export declare function resolveLocalInputs(name: string, args: Record<string, unknown>, ctx: {
17
+ apiUrl: string;
18
+ auth: AuthContext;
19
+ }): Promise<Record<string, unknown>>;
20
+ /**
21
+ * Build the `dcd mcp` server. Without a reachable hosted endpoint (no
22
+ * credentials yet, or an API without it), it still serves the dcd_* tools
23
+ * and says why on stderr.
24
+ */
25
+ export declare function createBridgeServer(opts?: {
26
+ callTimeoutMs?: number;
27
+ }): Promise<Server>;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `dcd mcp`: one stdio MCP server with two sets of tools (dcd plan 4.4).
3
+ *
4
+ * - The `dcd_*` cloud-test tools, from the same in-process server `dcd-mcp`
5
+ * runs (createServer), reached over an in-memory transport.
6
+ * - The device tools, proxied from the hosted endpoint at `<api>/mcp`, so
7
+ * they are defined (and contract-tested) once, in the API. The bridge adds
8
+ * what a remote server can't do: `install_app` takes a local `app_path`,
9
+ * and `run` takes a local `path` (a flow file, or a directory uploaded as
10
+ * a bundle).
11
+ *
12
+ * Auth is the CLI's: an API key or a `dcd login` session (getContext). The
13
+ * hosted endpoint accepts both. A session is renewed before it expires, and
14
+ * the remote connection is remade with it.
15
+ *
16
+ * stdout is the JSON-RPC channel: everything else goes to stderr.
17
+ */
18
+ import { readFile, stat } from 'node:fs/promises';
19
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
20
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
21
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
22
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
23
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
24
+ import { uploadLocalApp } from '../utils/app-upload.js';
25
+ import { getCliVersion } from '../utils/cli.js';
26
+ import { uploadFlowBundle } from '../utils/device-bundle.js';
27
+ import { getContext, isReadOnly, logStderr } from './context.js';
28
+ import { errorResult, runTool } from './helpers.js';
29
+ import { createServer } from './server.js';
30
+ const INSTRUCTIONS = 'DeviceCloud tools. dcd_* tools run Maestro test suites in the cloud. The device tools lease a cloud iOS or Android device for you to drive: start_device, then inspect_screen / take_screenshot, launch_app, run, and stop_device when done. Call cheat_sheet first for Maestro syntax. Local files: install_app takes app_path and run takes path (a flow file or a directory), which this server uploads for you.';
31
+ /**
32
+ * How long one forwarded call may take. Longer than dcd_run_cloud_test's
33
+ * longest wait (3600s) plus an upload. The MCP client calling this server
34
+ * still applies its own timeout, and its cancel is passed through.
35
+ */
36
+ export const CALL_TIMEOUT_MS = 2 * 60 * 60 * 1000;
37
+ const READ_ONLY_INSTRUCTIONS = 'DeviceCloud tools, read-only: nothing here starts a device, runs a test or changes anything. dcd_* tools read cloud test runs and their results. The device tools read open device sessions: list_devices, get_device_status, inspect_screen, take_screenshot, get_logs.';
38
+ /** The inputs this bridge adds to hosted tools, and what they say. */
39
+ const LOCAL_INPUTS = {
40
+ install_app: {
41
+ description: ' Or pass app_path, a build on this machine (.apk, or a simulator .app / .zip / Expo .tar.gz), which is uploaded first.',
42
+ properties: {
43
+ app_path: {
44
+ type: 'string',
45
+ description: 'A build on this machine to upload and install. Pass this, app_binary_id or app_url.',
46
+ },
47
+ },
48
+ },
49
+ run: {
50
+ description: ' Or pass path: a flow file on this machine (sent as yaml), or a directory of flows (uploaded as a bundle; flow picks the entry file inside it).',
51
+ properties: {
52
+ path: {
53
+ type: 'string',
54
+ description: 'A local flow file, or a directory of flows. Pass this, yaml or flow_upload_id.',
55
+ },
56
+ },
57
+ },
58
+ };
59
+ /** A hosted tool with this bridge's local inputs added. */
60
+ export function withLocalInputs(tool) {
61
+ const extra = LOCAL_INPUTS[tool.name];
62
+ if (!extra)
63
+ return tool;
64
+ return {
65
+ ...tool,
66
+ description: `${tool.description ?? ''}${extra.description}`,
67
+ inputSchema: {
68
+ ...tool.inputSchema,
69
+ properties: { ...tool.inputSchema.properties, ...extra.properties },
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * Do the local part of a call (uploads, reading a file) and return the
75
+ * arguments the hosted tool takes.
76
+ */
77
+ export async function resolveLocalInputs(name, args, ctx) {
78
+ if (name === 'install_app' && typeof args.app_path === 'string') {
79
+ const { app_path: filePath, ...rest } = args;
80
+ logStderr(`Uploading ${filePath}…`);
81
+ const binaryId = await uploadLocalApp({
82
+ ...ctx,
83
+ filePath: filePath,
84
+ log: logStderr,
85
+ showProgress: false,
86
+ });
87
+ return { ...rest, app_binary_id: binaryId };
88
+ }
89
+ if (name === 'run' && typeof args.path === 'string') {
90
+ const { path: flowPath, ...rest } = args;
91
+ const info = await stat(flowPath).catch(() => null);
92
+ if (!info)
93
+ throw new Error(`No such file or directory: ${flowPath}`);
94
+ if (info.isDirectory()) {
95
+ logStderr(`Uploading the flows in ${flowPath}…`);
96
+ const bundle = await uploadFlowBundle({ ...ctx, dir: flowPath, log: logStderr });
97
+ return { ...rest, flow_upload_id: bundle.id };
98
+ }
99
+ return { ...rest, yaml: await readFile(flowPath, 'utf8') };
100
+ }
101
+ return args;
102
+ }
103
+ /**
104
+ * The hosted device tools, over one connection made on first use and shared
105
+ * by every call.
106
+ *
107
+ * Auth goes on each request, not on the connection. Remaking the connection
108
+ * when a session is renewed would close it under calls still running (a `run`
109
+ * can take minutes), and they would fail with "Connection closed" while the
110
+ * flow carried on. The hosted /mcp is stateless, so nothing ties the
111
+ * connection to the auth it started with.
112
+ */
113
+ class RemoteDevices {
114
+ connecting = null;
115
+ tools = null;
116
+ /** Calls that arrive together share one connect. A failed one is retried by the next call. */
117
+ connect() {
118
+ this.connecting ??= this.open().catch((error) => {
119
+ this.connecting = null;
120
+ throw error;
121
+ });
122
+ return this.connecting;
123
+ }
124
+ async open() {
125
+ const { apiUrl } = await getContext();
126
+ const client = new Client({ name: 'dcd-mcp-bridge', version: getCliVersion() });
127
+ await client.connect(new StreamableHTTPClientTransport(new URL(`${apiUrl}/mcp`), {
128
+ fetch: async (url, init) => {
129
+ const { auth } = await getContext();
130
+ const headers = new Headers(init?.headers);
131
+ for (const [key, value] of Object.entries(auth.headers))
132
+ headers.set(key, value);
133
+ return fetch(url, { ...init, headers });
134
+ },
135
+ }));
136
+ return client;
137
+ }
138
+ async list() {
139
+ if (this.tools)
140
+ return this.tools;
141
+ const client = await this.connect();
142
+ const { tools } = await client.listTools();
143
+ this.tools = tools;
144
+ return tools;
145
+ }
146
+ has(name) {
147
+ return this.tools?.some((t) => t.name === name) ?? false;
148
+ }
149
+ /** Annotated read-only by the hosted server: changes nothing, costs nothing. */
150
+ isReadOnly(name) {
151
+ return this.tools?.find((t) => t.name === name)?.annotations?.readOnlyHint === true;
152
+ }
153
+ }
154
+ /**
155
+ * Build the `dcd mcp` server. Without a reachable hosted endpoint (no
156
+ * credentials yet, or an API without it), it still serves the dcd_* tools
157
+ * and says why on stderr.
158
+ */
159
+ export async function createBridgeServer(opts = {}) {
160
+ const callTimeoutMs = opts.callTimeoutMs ?? CALL_TIMEOUT_MS;
161
+ const [localClientSide, localServerSide] = InMemoryTransport.createLinkedPair();
162
+ await createServer().connect(localServerSide);
163
+ const local = new Client({ name: 'dcd-mcp-bridge', version: getCliVersion() });
164
+ await local.connect(localClientSide);
165
+ const localNames = new Set((await local.listTools()).tools.map((t) => t.name));
166
+ const remote = new RemoteDevices();
167
+ const readOnly = isReadOnly();
168
+ const server = new Server({ name: 'devicecloud', version: getCliVersion() }, {
169
+ capabilities: { tools: {} },
170
+ instructions: readOnly ? READ_ONLY_INSTRUCTIONS : INSTRUCTIONS,
171
+ });
172
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
173
+ const { tools: localTools } = await local.listTools();
174
+ let deviceTools = [];
175
+ try {
176
+ deviceTools = (await remote.list())
177
+ // Read-only mode keeps only the tools that change nothing.
178
+ .filter((t) => !readOnly || t.annotations?.readOnlyHint === true)
179
+ .map((t) => withLocalInputs(t));
180
+ }
181
+ catch (error) {
182
+ logStderr(`Device tools unavailable: ${error instanceof Error ? error.message : String(error)}. Serving the dcd_* tools only.`);
183
+ }
184
+ return { tools: [...localTools, ...deviceTools] };
185
+ });
186
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
187
+ // The client's own timeout and cancel apply, not the SDK's 60s default:
188
+ // dcd_run_cloud_test can wait an hour, and a device run or upload is long.
189
+ const options = { signal: extra.signal, timeout: callTimeoutMs, resetTimeoutOnProgress: true };
190
+ const { name } = request.params;
191
+ const args = (request.params.arguments ?? {});
192
+ if (localNames.has(name)) {
193
+ return (await local.callTool({ name, arguments: args }, undefined, options));
194
+ }
195
+ return runTool(`device ${name}`, async () => {
196
+ await remote.list();
197
+ if (!remote.has(name))
198
+ return errorResult(`Unknown tool: ${name}`);
199
+ // The list hides these in read-only mode; refuse them if called anyway.
200
+ if (readOnly && !remote.isReadOnly(name)) {
201
+ return errorResult(`${name} is not available in read-only mode.`);
202
+ }
203
+ const client = await remote.connect();
204
+ const remoteArgs = await resolveLocalInputs(name, args, await getContext());
205
+ return (await client.callTool({ name, arguments: remoteArgs }, undefined, options));
206
+ });
207
+ });
208
+ return server;
209
+ }
package/dist/methods.js CHANGED
@@ -125,7 +125,7 @@ export const uploadBinary = async (config) => {
125
125
  if (exists && binaryId) {
126
126
  if (log) {
127
127
  ux.info(colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'));
128
- ux.action.stop(colors.info('Skipping upload'));
128
+ ux.action.stop('Skipping upload');
129
129
  }
130
130
  return binaryId;
131
131
  }
@@ -158,13 +158,13 @@ export const uploadBinary = async (config) => {
158
158
  startTime,
159
159
  });
160
160
  if (log) {
161
- ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
161
+ ux.action.stop(colors.success('Binary uploaded with ID: ') + formatId(uploadId));
162
162
  }
163
163
  return uploadId;
164
164
  }
165
165
  catch (error) {
166
166
  if (log) {
167
- ux.action.stop(colors.error('✗ Failed'));
167
+ ux.action.fail(colors.error('Failed'));
168
168
  }
169
169
  if (debug) {
170
170
  console.error('[DEBUG] === BINARY UPLOAD FAILED ===');
@@ -59,7 +59,7 @@ export class MoropoService {
59
59
  }
60
60
  catch (error) {
61
61
  if (!quiet && !json) {
62
- ux.action.stop('failed');
62
+ ux.action.fail('failed');
63
63
  }
64
64
  // Remove the temp directory (and any partially-written zip inside it)
65
65
  if (moropoDir) {
@@ -121,7 +121,7 @@ export class ResultsPollingService {
121
121
  // displayFinalResults. Anything else aborts mid-poll with the spinner
122
122
  // still live, which would corrupt the terminal under the error output.
123
123
  if (!options.json && !(error instanceof RunFailedError)) {
124
- ux.action.stop(colors.error('failed'));
124
+ ux.action.fail(colors.error('failed'));
125
125
  }
126
126
  throw error;
127
127
  }