Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Synthetic secret fixtures retained in Git history; tracked in pickforge/picklab#60.
01c7e60369b9e3a415218a6b23a397c2eeb9abd4:crates/pickforge-cli/tests/evidence.rs:generic-api-key:184
de467fbd14d00d4afa4946f610d05d724a6fa777:packages/browser/test/evidence-integration.test.ts:jwt:27
d3e4353d8fbd470deaaab30c61c7fdb0c5b14727:packages/browser/test/evidence-integration.test.ts:jwt:27
596c7c21cd5472c99db0f516988b5483354e2fe7:packages/core/test/redact.test.ts:jwt:258
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ tool (`desktop_click`/`move`/`scroll`/`drag`/`double_click`/`type`/`key`),
`desktop_launch` (a newly launched client could otherwise grab input focus),
and every DevTools relay request fail closed with a stable busy error —
`takeover_status` (MCP) / `picklab takeover status` (CLI) let an agent check
before retrying, and `request_user_input` is the recommended way to ask a
human to run it. `desktop_screenshot` is the only desktop tool left ungated
before retrying. Use `request_user_input` to ask a human to run it: legacy
clients use MCP elicitation when supported, while modern clients receive guidance
to relay the question in conversation and retry afterward. `desktop_screenshot` is the only desktop tool left ungated
(read-only).

The lease is a 30-second TTL, heartbeat-renewed-every-5-seconds record in the
Expand Down Expand Up @@ -302,7 +303,7 @@ reported as suppressed for an explicitly writable `--vnc-control` session.
- Android: `android_start`, `android_install_apk`, `android_launch_app`, `android_screenshot`, `android_tap`, `android_type`, `android_back`, `android_home`, `android_get_ui_tree`, `android_logcat`, `android_run_adb`
- Artifacts: `artifact_list`, `artifact_report`
- Takeover: `takeover_status` — check whether a session is under human control (see [Supervised pause and human takeover](#supervised-pause-and-human-takeover)); read-only, always safe to call
- User: `request_user_input` — ask the human a question (via MCP elicitation when the client supports it) and wait for the answer; never used for secrets
- User: `request_user_input` — ask the human a question; supported legacy clients use MCP elicitation, while modern clients receive relay-and-retry guidance; never used for secrets

Resources, addressable as `picklab://` URIs:

Expand Down
179 changes: 13 additions & 166 deletions bun.lock

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ GitHub release description, then reset it after the release is published.
completed writes while retaining backups. There is deliberately no durable
journal or daemon: a process interruption can leave a partially applied set;
owned temporary/backup artifacts are recognized so a later rerun converges it.
- Migrated the stdio MCP server to SDK v2 with stable dual-era handling:
supported 2024/2025 protocol revisions keep the legacy tool, resource, prompt,
and elicitation behavior, while modern MCP uses discovery and per-request
metadata. Modern clients cannot use interactive push elicitation here;
`request_user_input` returns relay-and-retry guidance instead. Tasks and MRTR
are intentionally not adopted; `tasks/list` remains method-not-found.

## Internal/release changes

- Added an unpublished, default-off Flutter integration alpha for `pickforge
Expand All @@ -33,9 +40,10 @@ GitHub release description, then reset it after the release is published.
Flutter workflow skill into Claude's skill root and/or the shared Codex/Pi
agent-skill root. It requires discoverable `dart` but never executes it; the
default base pack and every release surface remain unchanged.
- Raised the vulnerable `fast-uri` and `hono` overrides, plus lockfile
resolutions for both `brace-expansion` majors, `fast-uri`, `hono`,
`ip-address`, and `nanoid`, to patched releases.
- Raised lockfile resolutions for both `brace-expansion` majors,
`ip-address`, and `nanoid` to patched releases. The SDK v2 migration removes
the former Express/Hono dependency stack, so the obsolete `body-parser`,
`fast-uri`, and `hono` overrides and Hono advisory suppression are gone.
- Added a Cargo workspace (`crates/pickforge-cli`, `Cargo.lock` committed) and
a `rust` CI matrix for `ubuntu-latest` and `windows-latest` (fmt, clippy `-D
warnings`, tests). The Bun job, its pinned Bun version, and all release
Expand All @@ -46,6 +54,9 @@ GitHub release description, then reset it after the release is published.

### Tested

- MCP v2 focused validation passes: frozen install, root typecheck/lint/build,
protocol wire fixtures (including real transport self-close), CLI close-once,
user-input, evidence, and server tests.
- Pinned Bun 1.3.12 CI: frozen install, typecheck, lint, 1,133 tests pass,
one skips, coverage passes at 82.48% lines, and build passes.
- The pinned OSV Scanner v2.3.8 image reports no unfiltered advisories.
Expand All @@ -64,6 +75,10 @@ GitHub release description, then reset it after the release is published.

### Not tested yet

- MCP v2 validation uses protocol fixtures, not proprietary client binaries.
The full normal suite reached 1,097 passing tests with 44 skipped, but eight
desktop CLI tests could not start Xvfb in this environment; coverage has the
same Xvfb limitation and did not complete.
- macOS for the Rust binary.
- No packaging, installer, or distribution path for `pickforge` yet.

Expand Down
4 changes: 0 additions & 4 deletions osv-scanner.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
[[IgnoredVulns]]
id = "GHSA-frvp-7c67-39w9"
reason = "Below the high/critical gate; the fixed @hono/node-server release requires an upstream major-version update. Tracked in pickforge/picklab#60."

[[IgnoredVulns]]
id = "GHSA-g7r4-m6w7-qqqr"
reason = "Below the high/critical gate; the fixed esbuild release is outside current direct dependency ranges. Tracked in pickforge/picklab#60."
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
"node": "^20.19.0 || ^22.12.0 || >=23.0.0"
},
"overrides": {
"body-parser": "2.3.0",
"fast-uri": "3.1.5",
"hono": "4.12.34",
"postcss": "^8.5.18"
},
"scripts": {
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,14 @@
"prepack": "node -e \"const fs=require('node:fs');fs.copyFileSync('../../README.md','README.md');fs.copyFileSync('../../LICENSE','LICENSE')\""
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
"@modelcontextprotocol/server": "^2.0.0",
"@sentry/node": "^10.64.0",
"commander": "^14.0.0",
"chrome-devtools-mcp": "1.5.0",
"zod": "^4.0.0"
"zod": "^4.2.0"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.0.0",
"@pickforge/picklab-agent-installers": "*",
"@pickforge/picklab-android": "*",
"@pickforge/picklab-browser": "*",
Expand Down
46 changes: 33 additions & 13 deletions packages/cli/src/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
serveStdio,
StdioServerTransport,
type StdioServerHandle,
} from "@modelcontextprotocol/server/stdio";
import { createMcpServer } from "@pickforge/picklab-mcp-server";

// serveStdio owns `onclose`, so observe shutdown by wrapping close instead.
class ClosingStdioServerTransport extends StdioServerTransport {
constructor(private readonly onClosed: () => void) {
super();
}

override async close(): Promise<void> {
await super.close();
this.onClosed();
}
}

export async function runMcpServe(): Promise<number> {
const server = createMcpServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("picklab mcp server: listening on stdio");
return new Promise<number>((resolve) => {
let settled = false;
let handle: StdioServerHandle | undefined;
const finish = (): void => {
if (settled) {
return;
}
if (settled) return;
settled = true;
void server
.close()
.catch(() => {})
.then(() => resolve(0));
process.stdin.off("end", finish);
process.stdin.off("close", finish);
void (handle?.close() ?? Promise.resolve()).catch(() => {}).then(() => resolve(0));
};
server.server.onclose = finish;
const transport = new ClosingStdioServerTransport(finish);
process.stdin.on("end", finish);
process.stdin.on("close", finish);
handle = serveStdio(
({ era }) => createMcpServer({ era }),
{
legacy: "serve",
transport,
onerror: (error) => console.error("picklab mcp server:", error),
},
);
if (settled) void handle.close().catch(() => {});
console.error("picklab mcp server: listening on stdio");
});
}
4 changes: 2 additions & 2 deletions packages/cli/test/browser-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/devtools-real.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { findOnPath } from "@pickforge/picklab-desktop-linux";
import {
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/test/mcp-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { EventEmitter } from "node:events";
import { afterEach, expect, it, vi } from "vitest";

const { close, closeDuringStartup, serveStdio, StdioServerTransport } = vi.hoisted(() => {
const closeHandle = vi.fn(async () => {});
const closeSynchronously = { value: false };
class FakeTransport {
constructor(private readonly onClosed: () => void) {}
async close(): Promise<void> {
this.onClosed();
}
}
return {
close: closeHandle,
closeDuringStartup: closeSynchronously,
serveStdio: vi.fn(
(_factory: unknown, options: { transport: FakeTransport }) => {
if (closeSynchronously.value) void options.transport.close();
return { close: closeHandle };
},
),
StdioServerTransport: FakeTransport,
};
});

vi.mock("@modelcontextprotocol/server/stdio", () => ({
serveStdio,
StdioServerTransport,
}));
vi.mock("@pickforge/picklab-mcp-server", () => ({
createMcpServer: vi.fn(),
}));

import { runMcpServe } from "../src/commands/mcp.js";

afterEach(() => {
vi.restoreAllMocks();
close.mockClear();
closeDuringStartup.value = false;
serveStdio.mockClear();
});

it("closes the stdio serve handle once and logs readiness to stderr", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdin = process.stdin as EventEmitter;
const result = runMcpServe();
const transport = serveStdio.mock.calls[0]?.[1]?.transport as InstanceType<
typeof StdioServerTransport
>;
await transport.close();
stdin.emit("end");
stdin.emit("close");

await expect(result).resolves.toBe(0);
expect(close).toHaveBeenCalledTimes(1);
expect(serveStdio).toHaveBeenCalledWith(expect.any(Function), {
legacy: "serve",
transport: expect.any(StdioServerTransport),
onerror: expect.any(Function),
});
expect(stderr).toHaveBeenCalledWith("picklab mcp server: listening on stdio");
});

it("closes the handle when the transport closes synchronously during startup", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
closeDuringStartup.value = true;
const endListeners = process.stdin.listenerCount("end");
const closeListeners = process.stdin.listenerCount("close");

await expect(runMcpServe()).resolves.toBe(0);

expect(close).toHaveBeenCalledTimes(1);
expect(process.stdin.listenerCount("end")).toBe(endListeners);
expect(process.stdin.listenerCount("close")).toBe(closeListeners);
});
Loading
Loading