From 0a08f3ab64aea753afa6b72bd4a4bc7b8b7af25d Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 22 Jun 2026 10:44:12 +0100 Subject: [PATCH] feat: add dcd-mcp stdio MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a second bin (dcd-mcp) that exposes the CLI's service layer to MCP clients (Claude, Cursor, VS Code) over stdio. Reuses resolveAuth, the gateways, and the existing release pipeline — no auth or publish rebuild. Tools: dcd_list_devices, dcd_list_runs, dcd_get_status, dcd_download_artifacts (read-only), and dcd_run_cloud_test (billable, async-by-default, destructive-annotated, gated by --read-only / DCD_MCP_READONLY). - src/mcp/: index/server/context/helpers + one file per tool. stdout is the JSON-RPC channel, so tools call services/gateways directly with a stderr logger, never the commands layer or utils/cli logger. - Extract computeCommonRoot/buildTestMetadataMap into src/services/flow-paths.ts and reuse from cloud.ts so the CLI and MCP build identical server-side paths. - Telemetry: mcp tool invoked/completed/failed events (async flush). - Tests: flow-paths unit tests + a stdio integration suite driving the built bin with the real MCP client against the mock API. - Docs: README MCP section, server.json registry manifest, CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 + README.md | 35 ++ package.json | 9 +- pnpm-lock.yaml | 535 +++++++++++++++++++++++ server.json | 40 ++ src/commands/cloud.ts | 41 +- src/mcp/context.ts | 53 +++ src/mcp/helpers.ts | 46 ++ src/mcp/index.ts | 30 ++ src/mcp/server.ts | 33 ++ src/mcp/tools/download-artifacts.ts | 99 +++++ src/mcp/tools/get-status.ts | 49 +++ src/mcp/tools/list-devices.ts | 36 ++ src/mcp/tools/list-runs.ts | 72 +++ src/mcp/tools/run-cloud-test.ts | 267 +++++++++++ src/services/flow-paths.ts | 67 +++ src/services/telemetry.service.ts | 29 ++ test/integration/mcp.integration.test.ts | 136 ++++++ test/unit/flow-paths.test.ts | 74 ++++ 19 files changed, 1617 insertions(+), 38 deletions(-) create mode 100644 server.json create mode 100644 src/mcp/context.ts create mode 100644 src/mcp/helpers.ts create mode 100644 src/mcp/index.ts create mode 100644 src/mcp/server.ts create mode 100644 src/mcp/tools/download-artifacts.ts create mode 100644 src/mcp/tools/get-status.ts create mode 100644 src/mcp/tools/list-devices.ts create mode 100644 src/mcp/tools/list-runs.ts create mode 100644 src/mcp/tools/run-cloud-test.ts create mode 100644 src/services/flow-paths.ts create mode 100644 test/integration/mcp.integration.test.ts create mode 100644 test/unit/flow-paths.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e4d124b..fd9e362 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The CLI is citty-native: `src/index.ts` has a `#!/usr/bin/env node` shebang (preserved by tsc), and `package.json` points `bin.dcd` directly at `dist/index.js`. There is no `bin/` wrapper directory — don't add one. +The package ships a **second bin, `dcd-mcp`** (`bin.dcd-mcp` → `dist/mcp/index.js`, also shebanged + chmod'd by `pnpm build`). It's the MCP server — see the MCP section under Architecture. + ## Architecture Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`. @@ -39,3 +41,5 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl **Cross-repo auth surface.** The dcd API's `ApiKeyGuard` accepts either `x-app-api-key` (existing) or `Authorization: Bearer ` + `x-dcd-org: `. For Bearer it verifies the JWT, checks `user_org_profile` membership, and injects the org's api_key back into the request headers so existing `@Headers(APP_API_KEY_HEADER)` controller code keeps working unchanged. `dcd switch-org` calls `GET /me/orgs`, a JWT-only endpoint at `../dcd/api/src/apps/me/me.controller.ts`. **Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`. + +**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 once** (so `tools/list` works unauthenticated and auth errors surface as tool errors, not a boot crash) 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. diff --git a/README.md b/README.md index f3d027a..a656931 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,41 @@ $ dcd cloud --apiKey .myFlows/ See full documentation: [Docs](https://docs.devicecloud.dev) +## MCP server + +The same npm package ships an [MCP](https://modelcontextprotocol.io) server (`dcd-mcp` bin) so AI agents — Claude, Cursor, VS Code — can drive devicecloud.dev directly. + +Add it to your MCP client config: + +```jsonc +{ + "mcpServers": { + "devicecloud": { + "command": "npx", + "args": ["-y", "@devicecloud.dev/dcd", "dcd-mcp"], + "env": { "DEVICE_CLOUD_API_KEY": "" } + } + } +} +``` + +Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`. + +**Tools** + +| Tool | What it does | +| --- | --- | +| `dcd_list_devices` | Discover available devices, OS versions, and Maestro versions | +| `dcd_list_runs` | List recent test runs (filter by name/date, paginated) | +| `dcd_get_status` | Get the status + per-test results of a run | +| `dcd_download_artifacts` | Download a run's artifacts/report to disk | +| `dcd_run_cloud_test` | Submit a flow to run on the cloud (**billable**) | + +**Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` in `args`, or set `DCD_MCP_READONLY=1` in `env`. + +By default `dcd_run_cloud_test` is async: it returns an `uploadId` immediately, which you poll with `dcd_get_status`. Pass `wait: true` (bounded by `waitTimeoutSeconds`) to block until completion, or `dryRun: true` to preview the flows without submitting. + + ## Development Requires Node 22+ and [pnpm](https://pnpm.io). `pnpm install` builds the CLI and installs the git hooks automatically. diff --git a/package.json b/package.json index b6b9b79..b8b1b92 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "author": "devicecloud.dev", "bin": { - "dcd": "dist/index.js" + "dcd": "dist/index.js", + "dcd-mcp": "dist/mcp/index.js" }, "dependencies": { "@clack/prompts": "^1.2.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.99.1", "bplist-parser": "^0.3.2", "chalk": "4.1.2", @@ -15,7 +17,8 @@ "plist": "^3.1.0", "tar": "^7.5.11", "tus-js-client": "^4.3.1", - "yazl": "^3.3.1" + "yazl": "^3.3.1", + "zod": "^4.4.3" }, "description": "Better cloud maestro testing", "devDependencies": { @@ -59,7 +62,7 @@ }, "scripts": { "dcd": "tsx src/index.ts", - "build": "shx rm -rf dist && tsc -b && shx chmod +x dist/index.js", + "build": "shx rm -rf dist && tsc -b && shx chmod +x dist/index.js dist/mcp/index.js", "build:binaries": "node scripts/build-binaries.mjs", "lint": "eslint src test --ext .ts", "prepare": "pnpm build && husky", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c345db..d82760a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@supabase/supabase-js': specifier: ^2.99.1 version: 2.103.3 @@ -71,6 +74,9 @@ importers: yazl: specifier: ^3.3.1 version: 3.3.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@eslint/js': specifier: ^9.39.4 @@ -336,6 +342,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -360,6 +372,16 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -499,6 +521,10 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -509,9 +535,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -585,6 +622,10 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bplist-parser@0.3.2: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} @@ -619,6 +660,10 @@ packages: resolution: {integrity: sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==} engines: {node: '>=18.20'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -696,9 +741,33 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -758,6 +827,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -773,6 +846,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} @@ -782,6 +858,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -822,6 +902,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -922,10 +1005,32 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@1.0.0: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -945,6 +1050,9 @@ packages: fast-string-width@1.1.0: resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-wrap-ansi@0.1.6: resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} @@ -968,6 +1076,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -995,6 +1107,14 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1103,6 +1223,14 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hono@4.12.26: + resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -1112,6 +1240,10 @@ packages: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1132,6 +1264,9 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1140,6 +1275,14 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1220,6 +1363,9 @@ packages: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1277,6 +1423,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -1295,6 +1444,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1354,6 +1509,14 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1362,6 +1525,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.3: resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} engines: {node: 18 || 20 || >=22} @@ -1398,6 +1569,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -1423,6 +1598,10 @@ packages: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1451,6 +1630,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1481,6 +1664,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1500,6 +1687,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} @@ -1514,6 +1704,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -1538,6 +1732,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1545,12 +1743,24 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1579,6 +1789,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -1607,6 +1821,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1622,6 +1840,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -1635,10 +1856,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-javascript@7.0.5: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -1651,6 +1880,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -1703,6 +1935,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1775,6 +2011,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1804,6 +2044,10 @@ packages: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -1839,6 +2083,10 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1851,6 +2099,10 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1937,6 +2189,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@babel/helper-validator-identifier@7.28.5': {} @@ -2077,6 +2337,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hono/node-server@1.19.14(hono@4.12.26)': + dependencies: + hono: 4.12.26 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2101,6 +2365,28 @@ snapshots: dependencies: minipass: 7.1.3 + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.26) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.26 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2280,12 +2566,21 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -2293,6 +2588,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -2373,6 +2675,20 @@ snapshots: big-integer@1.6.52: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bplist-parser@0.3.2: dependencies: big-integer: 1.6.52 @@ -2406,6 +2722,8 @@ snapshots: builtin-modules@5.1.0: {} + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2487,10 +2805,25 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js-compat@3.49.0: dependencies: browserslist: 4.28.2 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -2555,6 +2888,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + depd@2.0.0: {} + diff@8.0.3: {} doctrine@2.1.0: @@ -2569,12 +2904,16 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + electron-to-chromium@1.5.344: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2692,6 +3031,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -2835,6 +3176,14 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + execa@1.0.0: dependencies: cross-spawn: 6.0.6 @@ -2845,6 +3194,44 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2865,6 +3252,8 @@ snapshots: dependencies: fast-string-truncated-width: 1.2.1 + fast-uri@3.1.2: {} + fast-wrap-ansi@0.1.6: dependencies: fast-string-width: 1.1.0 @@ -2885,6 +3274,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up-simple@1.0.1: {} find-up@5.0.0: @@ -2910,6 +3310,10 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -3018,10 +3422,24 @@ snapshots: he@1.2.0: {} + hono@4.12.26: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + husky@9.1.7: {} iceberg-js@0.8.1: {} + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3035,6 +3453,8 @@ snapshots: indent-string@5.0.0: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3043,6 +3463,10 @@ snapshots: interpret@1.4.0: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -3122,6 +3546,8 @@ snapshots: is-plain-obj@2.1.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3177,6 +3603,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@6.2.3: {} + js-base64@3.7.8: {} js-yaml@4.2.0: @@ -3189,6 +3617,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -3249,6 +3681,10 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3256,6 +3692,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.3: dependencies: brace-expansion: 5.0.6 @@ -3308,6 +3750,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + nice-try@1.0.5: {} node-apk@1.2.1: @@ -3331,6 +3775,8 @@ snapshots: dependencies: path-key: 2.0.1 + object-assign@4.1.1: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -3371,6 +3817,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3406,6 +3856,8 @@ snapshots: dependencies: callsites: 3.1.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@2.0.1: {} @@ -3419,6 +3871,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-to-regexp@8.4.2: {} + pathval@1.1.1: {} picocolors@1.1.1: {} @@ -3427,6 +3881,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.9.10 @@ -3447,6 +3903,11 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -3454,10 +3915,23 @@ snapshots: punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + readdirp@4.1.2: {} rechoir@0.6.2: @@ -3492,6 +3966,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -3518,6 +3994,16 @@ snapshots: reusify@1.1.0: {} + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3541,14 +4027,41 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + semver@5.7.2: {} semver@6.3.1: {} semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-javascript@7.0.5: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -3571,6 +4084,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -3629,6 +4144,8 @@ snapshots: sisteransi@1.0.5: {} + statuses@2.0.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3712,6 +4229,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3748,6 +4267,12 @@ snapshots: type-detect@4.1.0: {} + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -3803,6 +4328,8 @@ snapshots: undici-types@7.19.2: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -3818,6 +4345,8 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + vary@1.1.2: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -3917,3 +4446,9 @@ snapshots: buffer-crc32: 1.0.0 yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/server.json b/server.json new file mode 100644 index 0000000..a2e1b92 --- /dev/null +++ b/server.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.json", + "name": "dev.devicecloud/dcd", + "description": "Run Maestro mobile app tests on devicecloud.dev — submit flows, check status, and download artifacts.", + "version": "5.0.0-beta.0", + "repository": { + "url": "https://github.com/devicecloud-dev/dcd-cli", + "source": "github" + }, + "packages": [ + { + "registryType": "npm", + "identifier": "@devicecloud.dev/dcd", + "version": "5.0.0-beta.0", + "runtimeHint": "npx", + "transport": { "type": "stdio" }, + "packageArguments": [ + { "type": "positional", "value": "dcd-mcp", "valueHint": "dcd-mcp" } + ], + "environmentVariables": [ + { + "name": "DEVICE_CLOUD_API_KEY", + "description": "Your devicecloud.dev API key. Alternatively run `dcd login` once to use a stored session.", + "isRequired": true, + "isSecret": true + }, + { + "name": "DCD_MCP_READONLY", + "description": "Set to 1 to hide the billable dcd_run_cloud_test tool (recommended for autonomous agents).", + "isRequired": false + }, + { + "name": "DCD_API_URL", + "description": "Override the API base URL (e.g. for a non-production environment).", + "isRequired": false + } + ] + } + ] +} diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 883ed37..262b0bc 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -7,6 +7,10 @@ import { ApiGateway } from '../gateways/api-gateway'; import { uploadBinary, verifyAppZip, writeJSONFile } from '../methods'; import { DeviceValidationService } from '../services/device-validation.service'; import { plan } from '../services/execution-plan.service'; +import { + buildTestMetadataMap, + computeCommonRoot, +} from '../services/flow-paths'; import { MoropoService } from '../services/moropo.service'; import { ReportDownloadService } from '../services/report-download.service'; import { @@ -38,7 +42,6 @@ import { } from '../utils/compatibility'; import { resolveApiUrl } from '../utils/config-store'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo'; -import { toPortableRelativePath } from '../utils/paths'; import { box, colors, @@ -533,45 +536,13 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`); } - const pathsShortestToLongest = [ - ...testFileNames, - ...referencedFiles, - ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length); - - // Longest whole-segment directory prefix shared by every path. Segment - // comparison (not startsWith) so sibling dirs like `flows`/`flows-extra` - // can't merge, and the file segment itself is never consumed. '' when - // the paths share no root at all. - const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep)); - const shortestSegments = splitPaths[0]; - let matchedSegments = 0; - for (let i = 0; i < shortestSegments.length - 1; i++) { - if (splitPaths.every((segments) => segments[i] === shortestSegments[i])) { - matchedSegments = i + 1; - } else { - break; - } - } - const commonRoot = shortestSegments.slice(0, matchedSegments).join(path.sep); + const commonRoot = computeCommonRoot(testFileNames, referencedFiles); if (debug) { out(`[DEBUG] Common root directory: ${commonRoot}`); } - const testMetadataMap: Record = {}; - for (const [absolutePath, meta] of Object.entries(flowMetadata)) { - const normalizedPath = toPortableRelativePath(absolutePath, commonRoot); - const metadataRecord = meta as Record | null; - const flowName = - (metadataRecord?.name as string) || path.parse(absolutePath).name; - const rawTags = metadataRecord?.tags; - const tags = Array.isArray(rawTags) - ? rawTags.map(String) - : rawTags - ? [String(rawTags)] - : []; - testMetadataMap[normalizedPath] = { flowName, tags }; - } + const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot); if (debug) { out( diff --git a/src/mcp/context.ts b/src/mcp/context.ts new file mode 100644 index 0000000..49c0068 --- /dev/null +++ b/src/mcp/context.ts @@ -0,0 +1,53 @@ +/** + * Per-process MCP context: a single resolved AuthContext + API URL, plus the + * stderr logger every tool must use. + * + * stdio transport reserves **stdout** for the JSON-RPC frame stream — anything + * a tool prints there corrupts the protocol. Tools therefore call the services + * with `logStderr` (never the `utils/cli` `logger`, which writes to stdout and + * can `process.exit`). + */ +import type { AuthContext } from '../types/domain/auth.types'; +import { resolveAuth } from '../utils/auth'; +import { resolveApiUrl } from '../utils/config-store'; + +/** Write a line to stderr. Safe under stdio transport; stdout is reserved. */ +export function logStderr(message: string): void { + process.stderr.write(`${message}\n`); +} + +export interface McpContext { + apiUrl: string; + auth: AuthContext; +} + +let cached: McpContext | null = null; + +/** + * Resolve auth + API URL once per process, lazily on first tool invocation. + * + * Lazy so `initialize` / `tools/list` succeed before the user has supplied a + * credential (clients enumerate tools on connect), and so an auth failure + * surfaces as a tool error rather than crashing the server at boot. + * + * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login` + * session. The API URL honors `DCD_API_URL` (handy for pointing the server at + * dev/staging), then the logged-in env, then the prod default. + */ +export async function getContext(): Promise { + if (cached) return cached; + const auth = await resolveAuth({ apiKeyFlag: undefined }); + const apiUrl = resolveApiUrl(process.env.DCD_API_URL); + cached = { apiUrl, auth }; + return cached; +} + +/** + * Read-only mode hides the only state-changing / billable tool + * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents. + */ +export function isReadOnly(): boolean { + return ( + process.env.DCD_MCP_READONLY === '1' || process.argv.includes('--read-only') + ); +} diff --git a/src/mcp/helpers.ts b/src/mcp/helpers.ts new file mode 100644 index 0000000..0a5176f --- /dev/null +++ b/src/mcp/helpers.ts @@ -0,0 +1,46 @@ +/** + * Shared tool plumbing: structured results and a wrapper that records + * telemetry and converts any thrown error into an `isError` tool result (so a + * single failing tool never tears down the long-lived server). + */ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +import { telemetry } from '../services/telemetry.service'; + +/** A tool result whose text payload is pretty-printed JSON. */ +export function jsonResult(data: unknown): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; +} + +/** An error tool result the model can read and react to. */ +export function errorResult(message: string): CallToolResult { + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + }; +} + +/** + * Wrap a tool handler: emit start/success/failure telemetry, flush it + * (fire-and-forget — the process outlives the call), and translate thrown + * errors into an `isError` result instead of rejecting. + */ +export async function runTool( + name: string, + fn: () => Promise, +): Promise { + const start = Date.now(); + telemetry.recordMcpToolStart(name); + try { + const result = await fn(); + telemetry.recordMcpToolSuccess(name, Date.now() - start); + void telemetry.flush(); + return result; + } catch (error) { + telemetry.recordMcpToolFailure(name, error, Date.now() - start); + void telemetry.flush(); + return errorResult(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..0d59575 --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * devicecloud.dev MCP server (stdio transport). + * + * Exposes the CLI's service layer to MCP clients (Claude, Cursor, …) as tools. + * Auth is inherited from the CLI: `DEVICE_CLOUD_API_KEY` env or a stored + * `dcd login` session (see src/mcp/context.ts). All diagnostics go to stderr — + * stdout carries only JSON-RPC frames. + */ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import { telemetry } from '../services/telemetry.service'; + +import { isReadOnly, logStderr } from './context'; +import { createServer } from './server'; + +async function main(): Promise { + telemetry.setCommand('mcp'); + const server = createServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + logStderr( + `devicecloud.dev MCP server running on stdio${isReadOnly() ? ' (read-only)' : ''}`, + ); +} + +main().catch((error) => { + logStderr(`Fatal: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..21a42bb --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,33 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { getCliVersion } from '../utils/cli'; + +import { isReadOnly } from './context'; +import { registerDownloadArtifacts } from './tools/download-artifacts'; +import { registerGetStatus } from './tools/get-status'; +import { registerListDevices } from './tools/list-devices'; +import { registerListRuns } from './tools/list-runs'; +import { registerRunCloudTest } from './tools/run-cloud-test'; + +/** + * Build the devicecloud.dev MCP server with its tool set registered. Read-only + * tools are always present; the billable `dcd_run_cloud_test` is omitted in + * read-only mode (`DCD_MCP_READONLY=1` or `--read-only`). + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: 'devicecloud', + version: getCliVersion(), + }); + + registerListDevices(server); + registerListRuns(server); + registerGetStatus(server); + registerDownloadArtifacts(server); + + if (!isReadOnly()) { + registerRunCloudTest(server); + } + + return server; +} diff --git a/src/mcp/tools/download-artifacts.ts b/src/mcp/tools/download-artifacts.ts new file mode 100644 index 0000000..476fef7 --- /dev/null +++ b/src/mcp/tools/download-artifacts.ts @@ -0,0 +1,99 @@ +import * as path from 'node:path'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { ReportDownloadService } from '../../services/report-download.service'; +import { getContext, logStderr } from '../context'; +import { jsonResult, runTool } from '../helpers'; + +/** + * Download a completed run's artifacts (zip) and/or a formatted report to local + * disk. This reads cloud state and writes files locally — it does not change + * anything cloud-side, so it stays available in read-only mode. + * + * The underlying service swallows download failures into `warnLogger` rather + * than throwing (a missing-artifacts 404 isn't fatal), so we collect those + * warnings and return them — an empty `warnings` array means success. + */ +export function registerDownloadArtifacts(server: McpServer): void { + server.registerTool( + 'dcd_download_artifacts', + { + title: 'Download run artifacts', + description: + 'Download a completed test run\'s artifacts (videos/logs zip) and/or a formatted report to local disk. ' + + 'Returns the resolved output paths and any warnings (a non-empty warnings array means a download could not be produced, e.g. no results yet).', + inputSchema: { + uploadId: z.string().describe('UUID of the upload to download artifacts for'), + type: z + .enum(['ALL', 'FAILED']) + .optional() + .describe('Which tests to include artifacts for (default ALL)'), + artifactsPath: z + .string() + .optional() + .describe('Local path to write the artifacts zip (default ./artifacts.zip)'), + report: z + .enum(['junit', 'allure', 'html']) + .optional() + .describe('Also download a formatted report of this type'), + reportPath: z + .string() + .optional() + .describe('Local path for the report file (defaults depend on report type)'), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async (args) => + runTool('dcd_download_artifacts', async () => { + const { apiUrl, auth } = await getContext(); + const service = new ReportDownloadService(); + const warnings: string[] = []; + const warnLogger = (m: string) => { + warnings.push(m); + logStderr(m); + }; + + const artifactsPath = args.artifactsPath ?? './artifacts.zip'; + await service.downloadArtifacts({ + apiUrl, + auth, + uploadId: args.uploadId, + downloadType: args.type ?? 'ALL', + artifactsPath, + logger: logStderr, + warnLogger, + }); + + let reportPath: string | undefined; + if (args.report) { + reportPath = args.reportPath + ? path.resolve(args.reportPath) + : path.resolve( + args.report === 'junit' + ? 'report.xml' + : 'report.html', + ); + await service.downloadReports({ + apiUrl, + auth, + uploadId: args.uploadId, + reportType: args.report, + junitPath: args.report === 'junit' ? reportPath : undefined, + allurePath: args.report === 'allure' ? reportPath : undefined, + htmlPath: args.report === 'html' ? reportPath : undefined, + logger: logStderr, + warnLogger, + }); + } + + return jsonResult({ + uploadId: args.uploadId, + artifactsPath: path.resolve(artifactsPath), + reportPath, + warnings, + }); + }), + ); +} diff --git a/src/mcp/tools/get-status.ts b/src/mcp/tools/get-status.ts new file mode 100644 index 0000000..8df61d9 --- /dev/null +++ b/src/mcp/tools/get-status.ts @@ -0,0 +1,49 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { ApiGateway } from '../../gateways/api-gateway'; +import { getContext } from '../context'; +import { jsonResult, runTool } from '../helpers'; + +/** + * Status of a single upload by id or name. This is the polling primitive: after + * an async `dcd_run_cloud_test`, an agent calls this until `status` leaves + * PENDING/QUEUED/RUNNING. + */ +export function registerGetStatus(server: McpServer): void { + server.registerTool( + 'dcd_get_status', + { + title: 'Get test run status', + description: + 'Get the status of a single upload (test run) by upload id or name. ' + + 'Provide exactly one of uploadId or name. ' + + 'Returns: { status, name, createdAt, tests: [{ name, status, durationSeconds, failReason }] }. ' + + 'Poll this after an async run until status is no longer PENDING/QUEUED/RUNNING.', + inputSchema: { + uploadId: z + .string() + .optional() + .describe('UUID of the upload (as returned by dcd_run_cloud_test or dcd_list_runs)'), + name: z.string().optional().describe('Name of the upload to look up'), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async (args) => + runTool('dcd_get_status', async () => { + if (args.uploadId && args.name) { + throw new Error('Provide only one of uploadId or name, not both.'); + } + if (!args.uploadId && !args.name) { + throw new Error('Provide one of uploadId or name.'); + } + + const { apiUrl, auth } = await getContext(); + const status = await ApiGateway.getUploadStatus(apiUrl, auth, { + uploadId: args.uploadId, + name: args.name, + }); + return jsonResult(status); + }), + ); +} diff --git a/src/mcp/tools/list-devices.ts b/src/mcp/tools/list-devices.ts new file mode 100644 index 0000000..f0b1236 --- /dev/null +++ b/src/mcp/tools/list-devices.ts @@ -0,0 +1,36 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { fetchCompatibilityData } from '../../utils/compatibility'; +import { getContext } from '../context'; +import { jsonResult, runTool } from '../helpers'; + +/** + * Discovery tool: the matrix of devices, OS versions, and Maestro versions the + * org can run. Agents should call this before `dcd_run_cloud_test` so they pass + * valid `iosDevice` / `iosVersion` / `androidDevice` / `androidApiLevel` values. + */ +export function registerListDevices(server: McpServer): void { + server.registerTool( + 'dcd_list_devices', + { + title: 'List available devices', + description: + 'List the iOS and Android devices, OS versions, and Maestro versions available on devicecloud.dev. ' + + 'Use this to discover valid device/version values before submitting a test run. ' + + 'Returns: { ios, android, androidPlay, maestro }, where each platform maps OS version → supported device list.', + inputSchema: {}, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async () => + runTool('dcd_list_devices', async () => { + const { apiUrl, auth } = await getContext(); + const data = await fetchCompatibilityData(apiUrl, auth); + return jsonResult({ + ios: data.ios, + android: data.android, + androidPlay: data.androidPlay, + maestro: data.maestro, + }); + }), + ); +} diff --git a/src/mcp/tools/list-runs.ts b/src/mcp/tools/list-runs.ts new file mode 100644 index 0000000..7c4f692 --- /dev/null +++ b/src/mcp/tools/list-runs.ts @@ -0,0 +1,72 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { ApiGateway } from '../../gateways/api-gateway'; +import { getContext } from '../context'; +import { jsonResult, runTool } from '../helpers'; + +/** List recent flow uploads for the org, with optional filters + pagination. */ +export function registerListRuns(server: McpServer): void { + server.registerTool( + 'dcd_list_runs', + { + title: 'List recent test runs', + description: + 'List recent flow uploads (test runs) for your organization, most recent first. ' + + 'Supports name (with * wildcard), date range, and pagination. ' + + 'Returns: { uploads: [{ id, name, created_at, consoleUrl }], total, limit, offset }. ' + + 'Use the returned id with dcd_get_status or dcd_download_artifacts.', + inputSchema: { + name: z + .string() + .optional() + .describe('Filter by upload name; supports a * wildcard, e.g. "nightly-*"'), + from: z + .string() + .optional() + .describe('Only uploads created on or after this ISO 8601 date (e.g. 2024-01-01)'), + to: z + .string() + .optional() + .describe('Only uploads created on or before this ISO 8601 date'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Maximum uploads to return (default 20)'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of uploads to skip, for pagination (default 0)'), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async (args) => + runTool('dcd_list_runs', async () => { + const { apiUrl, auth } = await getContext(); + for (const [key, value] of [ + ['from', args.from], + ['to', args.to], + ] as const) { + if (value && Number.isNaN(Date.parse(value))) { + throw new Error( + `Invalid --${key} date "${value}". Use ISO 8601 format (e.g. 2024-01-01).`, + ); + } + } + + const response = await ApiGateway.listUploads(apiUrl, auth, { + name: args.name, + from: args.from, + to: args.to, + limit: args.limit ?? 20, + offset: args.offset ?? 0, + }); + return jsonResult(response); + }), + ); +} diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts new file mode 100644 index 0000000..021b269 --- /dev/null +++ b/src/mcp/tools/run-cloud-test.ts @@ -0,0 +1,267 @@ +import * as path from 'node:path'; + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { ApiGateway } from '../../gateways/api-gateway'; +import { plan } from '../../services/execution-plan.service'; +import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths'; +import { DeviceValidationService } from '../../services/device-validation.service'; +import { TestSubmissionService } from '../../services/test-submission.service'; +import { VersionService } from '../../services/version.service'; +import { uploadBinary, verifyAppZip } from '../../methods'; +import { getCliVersion } from '../../utils/cli'; +import { fetchCompatibilityData } from '../../utils/compatibility'; +import { getConsoleUrl } from '../../utils/styling'; +import { getContext, logStderr } from '../context'; +import { jsonResult, runTool } from '../helpers'; + +const SUPPORTED_APP_EXTENSIONS = ['.apk', '.app', '.zip']; +const POLL_INTERVAL_MS = 10_000; +const TERMINAL_STATUSES = new Set(['PASSED', 'FAILED', 'CANCELLED', 'ERROR']); + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Submit a Maestro flow (or directory of flows) to devicecloud.dev. + * + * This is the only state-changing / billable tool — it consumes test minutes, + * so it is hidden in read-only mode and annotated as non-read-only/destructive + * so clients can gate it behind confirmation. Defaults to async (submit and + * return the upload id); set `wait: true` to block until completion, bounded by + * `waitTimeoutSeconds`. + * + * Mirrors the `dcd cloud` command's submission path but headless: no Expo URL + * download, mitm, GitHub metadata, or JSON-file output. Use the CLI for those. + */ +export function registerRunCloudTest(server: McpServer): void { + server.registerTool( + 'dcd_run_cloud_test', + { + title: 'Run a cloud test', + description: + 'Submit a Maestro flow (or a directory of flows) to run on devicecloud.dev. ' + + 'Consumes test minutes (billable). Provide a flowFile plus either appFile (a local .apk/.app/.zip) or appBinaryId (a previously uploaded binary). ' + + 'Defaults to async: returns { uploadId, consoleUrl, status: "PENDING", tests } immediately — poll dcd_get_status with the uploadId. ' + + 'Set wait: true to block until the run finishes (bounded by waitTimeoutSeconds). ' + + 'Set dryRun: true to preview which flows would run without submitting.', + inputSchema: { + flowFile: z + .string() + .describe('Path to a Maestro flow .yaml/.yml file, or a directory of flows'), + appFile: z + .string() + .optional() + .describe('Path to a local app binary (.apk, .app, or .zip). Mutually exclusive with appBinaryId.'), + appBinaryId: z + .string() + .optional() + .describe('ID of a previously uploaded binary. Mutually exclusive with appFile.'), + iosVersion: z.string().optional().describe('iOS version, e.g. "17"'), + iosDevice: z.string().optional().describe('iOS device, e.g. "iphone-14"'), + androidApiLevel: z.string().optional().describe('Android API level, e.g. "34"'), + androidDevice: z.string().optional().describe('Android device, e.g. "pixel-7"'), + googlePlay: z.boolean().optional().describe('Use a Google Play-enabled Android image'), + name: z.string().optional().describe('A name for this run'), + env: z + .array(z.string()) + .optional() + .describe('Environment variables as KEY=VALUE strings'), + includeTags: z.array(z.string()).optional().describe('Only run flows with these tags'), + excludeTags: z.array(z.string()).optional().describe('Skip flows with these tags'), + excludeFlows: z.array(z.string()).optional().describe('Flow paths/patterns to exclude'), + maestroVersion: z.string().optional().describe('Pin a specific Maestro version'), + retry: z.number().int().min(0).max(2).optional().describe('Auto-retry failed tests (max 2)'), + runnerType: z + .enum(['default', 'm4', 'm1', 'gpu1', 'cpu1']) + .optional() + .describe('Runner type (default "default")'), + configFile: z.string().optional().describe('Path to a workspace config.yaml'), + ignoreShaCheck: z + .boolean() + .optional() + .describe('Force re-upload of the binary even if an identical one exists'), + dryRun: z.boolean().optional().describe('Preview flows that would run without submitting'), + wait: z + .boolean() + .optional() + .describe('Block until the run completes instead of returning immediately (default false)'), + waitTimeoutSeconds: z + .number() + .int() + .min(30) + .max(3600) + .optional() + .describe('Max seconds to wait when wait is true (default 600)'), + }, + annotations: { + title: 'Run a cloud test', + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }, + }, + async (args) => + runTool('dcd_run_cloud_test', async () => { + if (args.appFile && args.appBinaryId) { + throw new Error('Provide only one of appFile or appBinaryId, not both.'); + } + + const { apiUrl, auth } = await getContext(); + const cliVersion = getCliVersion(); + const compatibilityData = await fetchCompatibilityData(apiUrl, auth); + + const deviceValidation = new DeviceValidationService(); + deviceValidation.validateiOSDevice( + args.iosVersion, + args.iosDevice, + compatibilityData, + { logger: logStderr }, + ); + deviceValidation.validateAndroidDevice( + args.androidApiLevel, + args.androidDevice, + Boolean(args.googlePlay), + compatibilityData, + { logger: logStderr }, + ); + + const resolvedMaestroVersion = new VersionService().resolveMaestroVersion( + args.maestroVersion, + compatibilityData, + { logger: logStderr }, + ); + + // Directory inputs must end in a separator so the planner treats them + // as a workspace rather than a single file. + let flowFile = path.resolve(args.flowFile); + if ( + !flowFile.endsWith('.yaml') && + !flowFile.endsWith('.yml') && + !flowFile.endsWith('/') + ) { + flowFile += '/'; + } + + const executionPlan = await plan({ + input: flowFile, + includeTags: args.includeTags ?? [], + excludeTags: args.excludeTags ?? [], + excludeFlows: args.excludeFlows, + configFile: args.configFile, + }); + + const commonRoot = computeCommonRoot( + executionPlan.flowsToRun, + executionPlan.referencedFiles, + ); + const testMetadataMap = buildTestMetadataMap( + executionPlan.flowMetadata, + commonRoot, + ); + + if (args.dryRun) { + return jsonResult({ + dryRun: true, + flows: Object.entries(testMetadataMap).map(([file, meta]) => ({ + file, + flowName: meta.flowName, + tags: meta.tags, + })), + sequentialFlowCount: executionPlan.sequence?.flows.length ?? 0, + }); + } + + // Resolve the binary: existing id, or upload the local file. + let appBinaryId = args.appBinaryId; + if (!appBinaryId) { + if (!args.appFile) { + throw new Error('Provide either appFile or appBinaryId.'); + } + if (!SUPPORTED_APP_EXTENSIONS.some((ext) => args.appFile!.endsWith(ext))) { + throw new Error( + `App file must be one of: ${SUPPORTED_APP_EXTENSIONS.join(', ')} (got ${args.appFile}).`, + ); + } + if (args.appFile.endsWith('.zip')) { + await verifyAppZip(args.appFile); + } + appBinaryId = await uploadBinary({ + auth, + apiUrl, + filePath: args.appFile, + ignoreShaCheck: Boolean(args.ignoreShaCheck), + log: false, + }); + } + + const { continueOnFailure = true } = executionPlan.sequence ?? {}; + const testFormData = await new TestSubmissionService().buildTestFormData({ + appBinaryId, + cliVersion, + commonRoot, + continueOnFailure, + executionPlan, + flowFile, + env: args.env ?? [], + googlePlay: Boolean(args.googlePlay), + androidApiLevel: args.androidApiLevel, + androidDevice: args.androidDevice, + iOSDevice: args.iosDevice, + iOSVersion: args.iosVersion, + name: args.name, + runnerType: args.runnerType ?? 'default', + maestroVersion: resolvedMaestroVersion, + retry: args.retry, + logger: logStderr, + }); + + const { message, results } = await ApiGateway.uploadFlow( + apiUrl, + auth, + testFormData, + ); + if (!results?.length) { + throw new Error(`No tests were created: ${message}`); + } + + const uploadId = results[0].test_upload_id as string; + const consoleUrl = getConsoleUrl(apiUrl, uploadId, results[0].id); + const tests = results.map((r) => ({ + fileName: r.test_file_name, + flowName: + testMetadataMap[r.test_file_name]?.flowName || + path.parse(r.test_file_name).name, + tags: testMetadataMap[r.test_file_name]?.tags ?? [], + status: r.status, + })); + + if (!args.wait) { + return jsonResult({ uploadId, consoleUrl, status: 'PENDING', tests, message }); + } + + // Bounded poll. The run continues in the cloud regardless of timeout — + // the caller can resume with dcd_get_status using the returned uploadId. + const deadline = + Date.now() + (args.waitTimeoutSeconds ?? 600) * 1000; + for (;;) { + const status = await ApiGateway.getUploadStatus(apiUrl, auth, { uploadId }); + if (TERMINAL_STATUSES.has(status.status)) { + return jsonResult({ uploadId, consoleUrl, status: status.status, tests: status.tests }); + } + if (Date.now() + POLL_INTERVAL_MS >= deadline) { + return jsonResult({ + uploadId, + consoleUrl, + status: status.status, + timedOut: true, + tests: status.tests, + message: `Still running after ${args.waitTimeoutSeconds ?? 600}s; poll dcd_get_status with uploadId ${uploadId}.`, + }); + } + await sleep(POLL_INTERVAL_MS); + } + }), + ); +} diff --git a/src/services/flow-paths.ts b/src/services/flow-paths.ts new file mode 100644 index 0000000..0e14f5d --- /dev/null +++ b/src/services/flow-paths.ts @@ -0,0 +1,67 @@ +/** + * Pure helpers for turning absolute flow paths into the server-side relative + * keys that submission, the polling layer, and JSON output all share. + * + * Extracted from the cloud command so the MCP `dcd_run_cloud_test` tool builds + * byte-identical paths without duplicating the logic. + */ +import * as path from 'node:path'; + +import { toPortableRelativePath } from '../utils/paths'; + +/** + * Longest whole-segment directory prefix shared by every flow + referenced + * file path. Segment comparison (not `startsWith`) so sibling dirs like + * `flows`/`flows-extra` can't merge, and the file segment itself is never + * consumed. Returns '' when the paths share no root at all (or none are given). + */ +export function computeCommonRoot( + testFileNames: string[], + referencedFiles: string[], +): string { + const pathsShortestToLongest = [...testFileNames, ...referencedFiles].sort( + (a, b) => a.split(path.sep).length - b.split(path.sep).length, + ); + if (pathsShortestToLongest.length === 0) return ''; + + const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep)); + const shortestSegments = splitPaths[0]; + let matchedSegments = 0; + for (let i = 0; i < shortestSegments.length - 1; i++) { + if (splitPaths.every((segments) => segments[i] === shortestSegments[i])) { + matchedSegments = i + 1; + } else { + break; + } + } + return shortestSegments.slice(0, matchedSegments).join(path.sep); +} + +export interface FlowMetadataEntry { + flowName: string; + tags: string[]; +} + +/** + * Build the portable-relative-path → {flowName, tags} map that results are + * keyed by. Flow name comes from the YAML `name` field, falling back to the + * filename without extension; tags are normalized to a string array. + */ +export function buildTestMetadataMap( + flowMetadata: Record | null>, + commonRoot: string, +): Record { + const map: Record = {}; + for (const [absolutePath, meta] of Object.entries(flowMetadata)) { + const normalizedPath = toPortableRelativePath(absolutePath, commonRoot); + const flowName = (meta?.name as string) || path.parse(absolutePath).name; + const rawTags = meta?.tags; + const tags = Array.isArray(rawTags) + ? rawTags.map(String) + : rawTags + ? [String(rawTags)] + : []; + map[normalizedPath] = { flowName, tags }; + } + return map; +} diff --git a/src/services/telemetry.service.ts b/src/services/telemetry.service.ts index e3c098b..593cd3a 100644 --- a/src/services/telemetry.service.ts +++ b/src/services/telemetry.service.ts @@ -64,6 +64,15 @@ class Telemetry { }; } + /** + * Override the command label attached to telemetry meta. The MCP server is + * long-lived and isn't a citty subcommand, so `inferCommandFromArgv` can't + * name it — `src/mcp/index.ts` calls this at boot. + */ + setCommand(command: string) { + this.command = command; + } + recordCommandStart() { this.startedAt = Date.now(); this.enqueue('info', 'cli.lifecycle', 'command started', { @@ -71,6 +80,26 @@ class Telemetry { }); } + recordMcpToolStart(tool: string) { + this.enqueue('info', 'cli.mcp', 'mcp tool invoked', { tool }); + } + + recordMcpToolSuccess(tool: string, durationMs: number) { + this.enqueue('info', 'cli.mcp', 'mcp tool completed', { + tool, + duration_ms: durationMs, + }); + } + + recordMcpToolFailure(tool: string, error: unknown, durationMs: number) { + this.enqueue('error', 'cli.mcp', 'mcp tool failed', { + tool, + duration_ms: durationMs, + error_message: error instanceof Error ? error.message : String(error), + error_name: error instanceof Error ? error.name : 'Error', + }); + } + recordCommandSuccess() { this.enqueue('info', 'cli.lifecycle', 'command completed', { duration_ms: Date.now() - this.startedAt, diff --git a/test/integration/mcp.integration.test.ts b/test/integration/mcp.integration.test.ts new file mode 100644 index 0000000..dc2d667 --- /dev/null +++ b/test/integration/mcp.integration.test.ts @@ -0,0 +1,136 @@ +/** + * MCP server integration tests. + * + * Spawns the built stdio server (dist/mcp/index.js) and drives it with the real + * MCP client over a JSON-RPC stdio transport, against the same mock API the CLI + * suites use. A successful tool round-trip also proves stdout stays clean: the + * client decodes newline-delimited JSON-RPC frames, so any stray stdout write + * from a tool would corrupt framing and fail the call. + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { expect } from 'chai'; +import * as path from 'node:path'; + +import { MOCK_API_KEY, MOCK_API_URL } from './helpers'; + +const MCP_BIN = path.resolve('dist/mcp/index.js'); + +function connect(extraArgs: string[] = []) { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [MCP_BIN, ...extraArgs], + env: { + ...process.env, + DEVICE_CLOUD_API_KEY: MOCK_API_KEY, + DCD_API_URL: MOCK_API_URL, + DCD_TELEMETRY_DISABLED: '1', + }, + }); + const client = new Client({ name: 'dcd-mcp-test', version: '1.0.0' }); + return { client, ready: client.connect(transport) }; +} + +/** Parse the JSON text payload of a tool result. */ +function parseResult(result: { content: Array<{ type: string; text?: string }>; isError?: boolean }) { + expect(result.isError, 'tool returned isError').to.not.equal(true); + const block = result.content.find((c) => c.type === 'text'); + expect(block, 'no text content block').to.exist; + return JSON.parse(block!.text as string); +} + +describe('MCP Server Integration Tests', () => { + describe('tool discovery', () => { + it('lists all five tools by default', async () => { + const { client, ready } = connect(); + await ready; + try { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name).sort(); + expect(names).to.deep.equal([ + 'dcd_download_artifacts', + 'dcd_get_status', + 'dcd_list_devices', + 'dcd_list_runs', + 'dcd_run_cloud_test', + ]); + } finally { + await client.close(); + } + }); + + it('hides the billable run tool in read-only mode', async () => { + const { client, ready } = connect(['--read-only']); + await ready; + try { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names).to.not.include('dcd_run_cloud_test'); + expect(names).to.include('dcd_list_runs'); + } finally { + await client.close(); + } + }); + + it('marks the run tool as non-read-only / destructive', async () => { + const { client, ready } = connect(); + await ready; + try { + const { tools } = await client.listTools(); + const run = tools.find((t) => t.name === 'dcd_run_cloud_test'); + expect(run?.annotations?.readOnlyHint).to.equal(false); + expect(run?.annotations?.destructiveHint).to.equal(true); + } finally { + await client.close(); + } + }); + }); + + describe('read-only tools against the mock API', () => { + it('dcd_list_runs returns the upload list', async () => { + const { client, ready } = connect(); + await ready; + try { + const result = await client.callTool({ name: 'dcd_list_runs', arguments: {} }); + const data = parseResult(result as Parameters[0]); + expect(data).to.have.property('uploads').that.is.an('array'); + expect(data).to.have.property('total'); + expect(data).to.have.property('limit'); + } finally { + await client.close(); + } + }); + + it('dcd_get_status returns a status for an upload id', async () => { + const { client, ready } = connect(); + await ready; + try { + const result = await client.callTool({ + name: 'dcd_get_status', + arguments: { uploadId: 'a3f1c2e4-0000-4000-8000-000000000000' }, + }); + const data = parseResult(result as Parameters[0]); + expect(data).to.have.property('status'); + expect(data).to.have.property('tests').that.is.an('array'); + } finally { + await client.close(); + } + }); + + it('surfaces a validation error as an isError result, not a crash', async () => { + const { client, ready } = connect(); + await ready; + try { + const result = (await client.callTool({ + name: 'dcd_get_status', + arguments: { uploadId: 'x', name: 'y' }, + })) as { content: Array<{ type: string; text?: string }>; isError?: boolean }; + expect(result.isError).to.equal(true); + const text = result.content.find((c) => c.type === 'text')?.text ?? ''; + expect(text).to.match(/only one of uploadId or name/i); + } finally { + await client.close(); + } + }); + }); +}); diff --git a/test/unit/flow-paths.test.ts b/test/unit/flow-paths.test.ts new file mode 100644 index 0000000..d9af84f --- /dev/null +++ b/test/unit/flow-paths.test.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; + +import { + buildTestMetadataMap, + computeCommonRoot, +} from '../../src/services/flow-paths'; + +describe('flow-paths', () => { + describe('computeCommonRoot', () => { + it('returns the deepest shared directory for sibling files', () => { + expect( + computeCommonRoot( + ['/a/b/flows/login.yaml', '/a/b/flows/checkout.yaml'], + [], + ), + ).to.equal('/a/b/flows'); + }); + + it('stops at a whole-segment boundary (no partial-prefix merge)', () => { + // `flows` and `flows-extra` share the `flow` substring but not a segment. + expect( + computeCommonRoot( + ['/a/b/flows/login.yaml', '/a/b/flows-extra/x.yaml'], + [], + ), + ).to.equal('/a/b'); + }); + + it('never consumes the file segment itself', () => { + expect(computeCommonRoot(['/a/only.yaml'], [])).to.equal('/a'); + }); + + it('folds referenced files into the calculation', () => { + expect( + computeCommonRoot(['/a/b/flows/login.yaml'], ['/a/b/helpers/util.js']), + ).to.equal('/a/b'); + }); + + it('returns empty string when given no paths', () => { + expect(computeCommonRoot([], [])).to.equal(''); + }); + }); + + describe('buildTestMetadataMap', () => { + it('keys by portable relative path and reads name + tags', () => { + const map = buildTestMetadataMap( + { '/a/b/flows/login.yaml': { name: 'Login', tags: ['smoke', 'auth'] } }, + '/a/b/flows', + ); + expect(map).to.deep.equal({ + './login.yaml': { flowName: 'Login', tags: ['smoke', 'auth'] }, + }); + }); + + it('falls back to the filename when no name is set', () => { + const map = buildTestMetadataMap( + { '/a/b/flows/checkout.yaml': {} }, + '/a/b/flows', + ); + expect(map['./checkout.yaml']).to.deep.equal({ + flowName: 'checkout', + tags: [], + }); + }); + + it('normalizes a single string tag into an array', () => { + const map = buildTestMetadataMap( + { '/a/b/flows/x.yaml': { tags: 'smoke' } }, + '/a/b/flows', + ); + expect(map['./x.yaml'].tags).to.deep.equal(['smoke']); + }); + }); +});