diff --git a/CLAUDE.md b/CLAUDE.md index bc760b8..0e91224 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,8 @@ Top-level `defineCommand` in `src/index.ts` wires eleven 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 hidden `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 `/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. + ## Contributing Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release): diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..4e7975f --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,34 @@ +import { defineCommand } from 'citty'; + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import { createBridgeServer } from '../mcp/bridge.js'; +import { isReadOnly, logStderr } from '../mcp/context.js'; +import { telemetry } from '../services/telemetry.service.js'; + +/** + * `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. + */ +export const mcpCommand = defineCommand({ + meta: { + name: 'mcp', + description: 'Run the DeviceCloud MCP server on stdio (cloud tests and cloud devices)', + // Listed in `dcd --help` with `dcd device`, once the device API is live. + hidden: true, + }, + args: { + 'read-only': { + type: 'boolean', + default: false, + description: 'Serve only tools that change nothing (also DCD_MCP_READONLY=1)', + }, + }, + async run() { + telemetry.setCommand('mcp'); + const server = await createBridgeServer(); + await server.connect(new StdioServerTransport()); + logStderr(`DeviceCloud MCP server running on stdio${isReadOnly() ? ' (read-only)' : ''}`); + }, +}); diff --git a/src/index.ts b/src/index.ts index f8041af..69fbc08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { listCommand } from './commands/list.js'; import { liveCommand } from './commands/live.js'; import { loginCommand } from './commands/login.js'; import { logoutCommand } from './commands/logout.js'; +import { mcpCommand } from './commands/mcp.js'; import { statusCommand } from './commands/status.js'; import { switchOrgCommand } from './commands/switch-org.js'; import { upgradeCommand } from './commands/upgrade.js'; @@ -39,6 +40,7 @@ const main = defineCommand({ artifacts: artifactsCommand, device: deviceCommand, live: liveCommand, + mcp: mcpCommand, login: loginCommand, logout: logoutCommand, whoami: whoamiCommand, diff --git a/src/mcp/bridge.ts b/src/mcp/bridge.ts new file mode 100644 index 0000000..a716a7b --- /dev/null +++ b/src/mcp/bridge.ts @@ -0,0 +1,234 @@ +/** + * `dcd mcp`: one stdio MCP server with two sets of tools (dcd plan 4.4). + * + * - The `dcd_*` cloud-test tools, from the same in-process server `dcd-mcp` + * runs (createServer), reached over an in-memory transport. + * - The device tools, proxied from the hosted endpoint at `/mcp`, so + * they are defined (and contract-tested) once, in the API. The bridge adds + * what a remote server can't do: `install_app` takes a local `app_path`, + * and `run` takes a local `path` (a flow file, or a directory uploaded as + * 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. + * + * stdout is the JSON-RPC channel: everything else goes to stderr. + */ +import { readFile, stat } from 'node:fs/promises'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + type CallToolResult, + ListToolsRequestSchema, + type Tool, +} from '@modelcontextprotocol/sdk/types.js'; + +import type { AuthContext } from '../types/domain/auth.types.js'; +import { uploadLocalApp } from '../utils/app-upload.js'; +import { getCliVersion } from '../utils/cli.js'; +import { uploadFlowBundle } from '../utils/device-bundle.js'; +import { getContext, isReadOnly, logStderr } from './context.js'; +import { errorResult, runTool } from './helpers.js'; +import { createServer } from './server.js'; + +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.'; + +/** + * How long one forwarded call may take. Longer than dcd_run_cloud_test's + * longest wait (3600s) plus an upload. The MCP client calling this server + * still applies its own timeout, and its cancel is passed through. + */ +export const CALL_TIMEOUT_MS = 2 * 60 * 60 * 1000; + +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.'; + +/** The inputs this bridge adds to hosted tools, and what they say. */ +const LOCAL_INPUTS: Record }> = { + install_app: { + description: + ' Or pass app_path, a build on this machine (.apk, or a simulator .app / .zip / Expo .tar.gz), which is uploaded first.', + properties: { + app_path: { + type: 'string', + description: 'A build on this machine to upload and install. Pass this, app_binary_id or app_url.', + }, + }, + }, + run: { + 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).', + properties: { + path: { + type: 'string', + description: 'A local flow file, or a directory of flows. Pass this, yaml or flow_upload_id.', + }, + }, + }, +}; + +/** A hosted tool with this bridge's local inputs added. */ +export function withLocalInputs(tool: Tool): Tool { + const extra = LOCAL_INPUTS[tool.name]; + if (!extra) return tool; + return { + ...tool, + description: `${tool.description ?? ''}${extra.description}`, + inputSchema: { + ...tool.inputSchema, + properties: { ...tool.inputSchema.properties, ...extra.properties }, + }, + }; +} + +/** + * Do the local part of a call (uploads, reading a file) and return the + * arguments the hosted tool takes. + */ +export async function resolveLocalInputs( + name: string, + args: Record, + ctx: { apiUrl: string; auth: AuthContext }, +): Promise> { + if (name === 'install_app' && typeof args.app_path === 'string') { + const { app_path: filePath, ...rest } = args; + logStderr(`Uploading ${filePath}…`); + const binaryId = await uploadLocalApp({ + ...ctx, + filePath: filePath as string, + log: logStderr, + showProgress: false, + }); + return { ...rest, app_binary_id: binaryId }; + } + + if (name === 'run' && typeof args.path === 'string') { + const { path: flowPath, ...rest } = args; + const info = await stat(flowPath as string).catch(() => null); + if (!info) throw new Error(`No such file or directory: ${flowPath}`); + if (info.isDirectory()) { + logStderr(`Uploading the flows in ${flowPath}…`); + const bundle = await uploadFlowBundle({ ...ctx, dir: flowPath as string, log: logStderr }); + return { ...rest, flow_upload_id: bundle.id }; + } + + return { ...rest, yaml: await readFile(flowPath as string, 'utf8') }; + } + + return args; +} + +/** The hosted device tools, connected lazily and remade when auth changes. */ +class RemoteDevices { + private client: Client | null = null; + private auth: AuthContext | null = null; + private tools: Tool[] | null = null; + + async connect(): Promise<{ client: Client; ctx: { apiUrl: string; auth: AuthContext } }> { + const ctx = await getContext(); + if (this.client && this.auth === ctx.auth) return { client: this.client, ctx }; + + await this.client?.close().catch(() => {}); + const client = new Client({ name: 'dcd-mcp-bridge', version: getCliVersion() }); + await client.connect( + new StreamableHTTPClientTransport(new URL(`${ctx.apiUrl}/mcp`), { + requestInit: { headers: { ...ctx.auth.headers } }, + }), + ); + this.client = client; + this.auth = ctx.auth; + return { client, ctx }; + } + + async list(): Promise { + if (this.tools) return this.tools; + const { client } = await this.connect(); + const { tools } = await client.listTools(); + this.tools = tools; + return tools; + } + + has(name: string): boolean { + return this.tools?.some((t) => t.name === name) ?? false; + } + + /** Annotated read-only by the hosted server: changes nothing, costs nothing. */ + isReadOnly(name: string): boolean { + return this.tools?.find((t) => t.name === name)?.annotations?.readOnlyHint === true; + } +} + +/** + * Build the `dcd mcp` server. Without a reachable hosted endpoint (no + * credentials yet, or an API without it), it still serves the dcd_* tools + * and says why on stderr. + */ +export async function createBridgeServer( + opts: { callTimeoutMs?: number } = {}, +): Promise { + const callTimeoutMs = opts.callTimeoutMs ?? CALL_TIMEOUT_MS; + const [localClientSide, localServerSide] = InMemoryTransport.createLinkedPair(); + await createServer().connect(localServerSide); + const local = new Client({ name: 'dcd-mcp-bridge', version: getCliVersion() }); + await local.connect(localClientSide); + const localNames = new Set((await local.listTools()).tools.map((t) => t.name)); + + const remote = new RemoteDevices(); + const readOnly = isReadOnly(); + + const server = new Server( + { name: 'devicecloud', version: getCliVersion() }, + { + capabilities: { tools: {} }, + instructions: readOnly ? READ_ONLY_INSTRUCTIONS : INSTRUCTIONS, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const { tools: localTools } = await local.listTools(); + let deviceTools: Tool[] = []; + try { + deviceTools = (await remote.list()) + // Read-only mode keeps only the tools that change nothing. + .filter((t) => !readOnly || t.annotations?.readOnlyHint === true) + .map((t) => withLocalInputs(t)); + } catch (error) { + logStderr( + `Device tools unavailable: ${error instanceof Error ? error.message : String(error)}. Serving the dcd_* tools only.`, + ); + } + + return { tools: [...localTools, ...deviceTools] }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + // The client's own timeout and cancel apply, not the SDK's 60s default: + // dcd_run_cloud_test can wait an hour, and a device run or upload is long. + const options = { signal: extra.signal, timeout: callTimeoutMs, resetTimeoutOnProgress: true }; + const { name } = request.params; + const args = (request.params.arguments ?? {}) as Record; + if (localNames.has(name)) { + return (await local.callTool({ name, arguments: args }, undefined, options)) as CallToolResult; + } + + return runTool(`device ${name}`, async () => { + await remote.list(); + if (!remote.has(name)) return errorResult(`Unknown tool: ${name}`); + // The list hides these in read-only mode; refuse them if called anyway. + if (readOnly && !remote.isReadOnly(name)) { + return errorResult(`${name} is not available in read-only mode.`); + } + const { client, ctx } = await remote.connect(); + const remoteArgs = await resolveLocalInputs(name, args, ctx); + return (await client.callTool({ name, arguments: remoteArgs }, undefined, options)) as CallToolResult; + }); + }); + + return server; +} diff --git a/test/unit/mcp-bridge.test.ts b/test/unit/mcp-bridge.test.ts new file mode 100644 index 0000000..32e2d2a --- /dev/null +++ b/test/unit/mcp-bridge.test.ts @@ -0,0 +1,203 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; + +import { createBridgeServer, resolveLocalInputs, withLocalInputs } from '../../src/mcp/bridge.js'; +import { clearContextCache } from '../../src/mcp/context.js'; + +/** + * A stand-in for the hosted /mcp: stateless Streamable HTTP, one server per + * POST, as the API runs it. Records each call and the auth header it saw. + */ +function startHosted(): Promise<{ calls: { args: unknown; auth?: string; name: string }[]; http: HttpServer; url: string }> { + const calls: { args: unknown; auth?: string; name: string }[] = []; + const http = createHttpServer(async (req, res) => { + // Stateless, as the API: no GET stream, no session to delete. + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = JSON.parse(Buffer.concat(chunks).toString() || '{}'); + const auth = req.headers['x-app-api-key'] as string | undefined; + const server = new McpServer({ name: 'hosted', version: '1' }); + const record = (name: string) => async (args: unknown) => { + calls.push({ name, args, auth }); + return { content: [{ type: 'text' as const, text: JSON.stringify({ ok: name }) }] }; + }; + server.registerTool( + 'install_app', + { + description: 'Install an app.', + inputSchema: { session_id: z.string().optional(), app_binary_id: z.string().optional() }, + }, + record('install_app'), + ); + server.registerTool( + 'run', + { description: 'Run Maestro.', inputSchema: { yaml: z.string().optional(), flow_upload_id: z.string().optional() } }, + record('run'), + ); + server.registerTool( + 'get_logs', + { description: 'Slow.', inputSchema: { ms: z.number() }, annotations: { readOnlyHint: true } }, + async ({ ms }) => { + await new Promise((r) => setTimeout(r, ms)); + return { content: [{ type: 'text' as const, text: 'late' }] }; + }, + ); + server.registerTool( + 'inspect_screen', + { description: 'Inspect.', inputSchema: {}, annotations: { readOnlyHint: true } }, + record('inspect_screen'), + ); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res, body); + }); + return new Promise((resolve) => { + http.listen(0, '127.0.0.1', () => { + const { port } = http.address() as AddressInfo; + resolve({ calls, http, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +describe('dcd mcp bridge', () => { + const ORIGINAL_ENV = { ...process.env }; + const ORIGINAL_ARGV = [...process.argv]; + let hosted: Awaited>; + let tempDir: string; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-bridge-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + process.env.DCD_TELEMETRY_DISABLED = '1'; + process.env.DEVICE_CLOUD_API_KEY = 'test-key'; + delete process.env.DCD_MCP_READONLY; + hosted = await startHosted(); + process.env.DCD_API_URL = hosted.url; + clearContextCache(); + }); + + afterEach(async () => { + process.env = { ...ORIGINAL_ENV }; + process.argv = [...ORIGINAL_ARGV]; + clearContextCache(); + hosted.http.closeAllConnections(); + await new Promise((r) => hosted.http.close(r)); + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + async function connect(callTimeoutMs?: number): Promise { + const server = await createBridgeServer({ callTimeoutMs }); + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(b); + const client = new Client({ name: 'agent', version: '1' }); + await client.connect(a); + return client; + } + + it('serves the dcd_* tools and the hosted device tools together', async () => { + const client = await connect(); + const names = (await client.listTools()).tools.map((t) => t.name); + expect(names).to.include.members(['dcd_list_runs', 'dcd_run_cloud_test', 'install_app', 'run', 'inspect_screen']); + const install = (await client.listTools()).tools.find((t) => t.name === 'install_app')!; + expect(install.inputSchema.properties).to.have.keys('session_id', 'app_binary_id', 'app_path'); + await client.close(); + }); + + it('forwards a device tool call with the CLI credentials', async () => { + const client = await connect(); + await client.listTools(); + 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' }]); + await client.close(); + }); + + it("applies the bridge's call timeout, not the SDK's 60s default", async () => { + // The agent's own client allows long calls; only the bridge's limit applies. + const client = await connect(300); + await client.listTools(); + const opts = { timeout: 10_000 }; + const fast = await client.callTool({ name: 'get_logs', arguments: { ms: 50 } }, undefined, opts); + expect(fast.isError).to.not.equal(true); + const slow = await client.callTool({ name: 'get_logs', arguments: { ms: 1000 } }, undefined, opts); + expect(slow.isError).to.equal(true); + expect(JSON.stringify(slow.content)).to.match(/timed out/i); + await client.close(); + }); + + it('sends a local flow file as yaml', async () => { + const flow = path.join(tempDir, 'login.yaml'); + fs.writeFileSync(flow, 'appId: x\n---\n- launchApp\n'); + const client = await connect(); + await client.listTools(); + await client.callTool({ name: 'run', arguments: { path: flow } }); + expect(hosted.calls[0]).to.deep.include({ name: 'run', args: { yaml: 'appId: x\n---\n- launchApp\n' } }); + await client.close(); + }); + + it('read-only mode keeps only device tools that change nothing', async () => { + process.env.DCD_MCP_READONLY = '1'; + const client = await connect(); + const names = (await client.listTools()).tools.map((t) => t.name); + expect(names).to.include('inspect_screen'); + expect(names).to.not.include.members(['install_app', 'run', 'dcd_run_cloud_test']); + + // Hidden is not enough: a call made anyway is refused, never forwarded. + const refused = await client.callTool({ name: 'run', arguments: { yaml: 'x' } }); + expect(refused.isError).to.equal(true); + expect(JSON.stringify(refused.content)).to.include('read-only mode'); + 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']); + await client.close(); + }); + + it('still serves the dcd_* tools when the hosted endpoint is unreachable', async () => { + process.env.DCD_API_URL = 'http://127.0.0.1:9'; + clearContextCache(); + const client = await connect(); + const names = (await client.listTools()).tools.map((t) => t.name); + expect(names).to.include('dcd_list_runs'); + expect(names).to.not.include('run'); + const result = await client.callTool({ name: 'run', arguments: { yaml: 'x' } }); + expect(result.isError).to.equal(true); + await client.close(); + }); + + describe('helpers', () => { + it('withLocalInputs adds the local inputs and leaves other tools alone', () => { + const tool = { name: 'run', description: 'Run.', inputSchema: { type: 'object' as const, properties: { yaml: {} } } }; + const patched = withLocalInputs(tool); + expect(patched.inputSchema.properties).to.have.keys('yaml', 'path'); + expect(patched.description).to.match(/^Run\. Or pass path/); + const other = { name: 'stop_device', inputSchema: { type: 'object' as const } }; + expect(withLocalInputs(other)).to.equal(other); + }); + + it('resolveLocalInputs leaves calls without local inputs alone, and refuses a missing path', async () => { + const ctx = { apiUrl: 'http://x', auth: { headers: {}, mode: 'apiKey' as const } }; + expect(await resolveLocalInputs('run', { yaml: 'y' }, ctx)).to.deep.equal({ yaml: 'y' }); + try { + await resolveLocalInputs('run', { path: path.join(tempDir, 'nope') }, ctx); + expect.fail('should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('No such file or directory'); + } + }); + }); +});