@devicecloud.dev/dcd 5.11.0-beta.1 → 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.
- package/dist/commands/device.d.ts +30 -0
- package/dist/commands/device.js +1106 -0
- package/dist/commands/live.d.ts +2 -0
- package/dist/commands/live.js +9 -0
- package/dist/commands/login.js +1 -16
- package/dist/commands/mcp.d.ts +12 -0
- package/dist/commands/mcp.js +29 -0
- package/dist/gateways/device-gateway.d.ts +88 -0
- package/dist/gateways/device-gateway.js +140 -0
- package/dist/index.js +4 -0
- package/dist/mcp/bridge.d.ts +27 -0
- package/dist/mcp/bridge.js +209 -0
- package/dist/methods.js +3 -3
- package/dist/services/moropo.service.js +1 -1
- package/dist/services/results-polling.service.js +1 -1
- package/dist/types/domain/device-session.types.d.ts +197 -0
- package/dist/types/domain/device-session.types.js +5 -0
- package/dist/utils/app-upload.d.ts +19 -0
- package/dist/utils/app-upload.js +69 -0
- package/dist/utils/auth.d.ts +0 -8
- package/dist/utils/auth.js +8 -24
- package/dist/utils/browser.d.ts +10 -0
- package/dist/utils/browser.js +35 -0
- package/dist/utils/device-artifacts.d.ts +55 -0
- package/dist/utils/device-artifacts.js +211 -0
- package/dist/utils/device-bundle.d.ts +22 -0
- package/dist/utils/device-bundle.js +102 -0
- package/dist/utils/device-output.d.ts +81 -0
- package/dist/utils/device-output.js +204 -0
- package/dist/utils/device-run.d.ts +29 -0
- package/dist/utils/device-run.js +105 -0
- package/dist/utils/progress.d.ts +4 -0
- package/dist/utils/progress.js +45 -21
- package/package.json +2 -2
package/dist/commands/live.d.ts
CHANGED
package/dist/commands/live.js
CHANGED
|
@@ -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
|
package/dist/commands/login.js
CHANGED
|
@@ -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(
|
|
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('
|
|
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.
|
|
167
|
+
ux.action.fail(colors.error('Failed'));
|
|
168
168
|
}
|
|
169
169
|
if (debug) {
|
|
170
170
|
console.error('[DEBUG] === BINARY UPLOAD FAILED ===');
|
|
@@ -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.
|
|
124
|
+
ux.action.fail(colors.error('failed'));
|
|
125
125
|
}
|
|
126
126
|
throw error;
|
|
127
127
|
}
|