From 2bb75f387820e02cc048bfdf3b79d85e57ddcf60 Mon Sep 17 00:00:00 2001 From: Simon Mak Date: Sat, 3 Oct 2026 07:40:54 +0800 Subject: [PATCH] feat: web intro landing page (fix /404) + remove/update_component lifecycle (v2.1.0) - public/index.html + favicon.svg served at / (vercel.json rewrite + security headers) - add remove_component + update_component MCP tools (consent-gated, value-blind) - TDQS behavioral-transparency pass on mutating tools - tool count 9->11 across docs/metadata; version 2.1.0 - verified: 55/55 tests, build ok, mcp-tdqs lint 0 errors --- AGENTS.md | 5 +- README.md | 2 + connector.json | 2 +- context7.json | 2 +- docs/CHANGELOG_WORKBENCH.md | 18 ++ docs/architecture/clone-mcp.md | 2 + .../architecture/opencode-runtime-snapshot.md | 6 +- glama.json | 2 +- llms.txt | 11 +- mcp-server/README.md | 4 +- mcp-server/src/clone.ts | 66 +++++ mcp-server/src/components.ts | 27 ++ mcp-server/src/tools.ts | 81 +++++- mcp-server/test/component-actions.test.ts | 43 +++ mcp-server/test/server.test.ts | 28 ++ package.json | 2 +- public/favicon.svg | 5 + public/index.html | 246 ++++++++++++++++++ server.json | 4 +- vercel.json | 12 +- 20 files changed, 548 insertions(+), 20 deletions(-) create mode 100644 mcp-server/test/component-actions.test.ts create mode 100644 public/favicon.svg create mode 100644 public/index.html diff --git a/AGENTS.md b/AGENTS.md index 9c59974..08f9b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,9 @@ offloaded to a compute box (`cs run "pnpm test"`) or CI — do not run them loca - `mcp-server/` — the `opencode-workbench` MCP server + connector (TypeScript). Tools: `get_workbench_info`, `inspect_target`, `plan_clone`, `apply_clone`, - `verify_clone`, `install_component`, `list_required_credentials`, - `run_auth_flow`, `bootstrap_host`. Local + SSH transports. + `verify_clone`, `install_component`, `remove_component`, `update_component`, + `list_required_credentials`, `run_auth_flow`, `bootstrap_host`. + Local + SSH transports. See `mcp-server/README.md` and `docs/architecture/clone-mcp.md`. - `connector.json` — connector manifest / registration snippet. - `plugins/` — the `memory.ts` + `doc-tools.ts` OpenCode plugins (portable, diff --git a/README.md b/README.md index 361249e..f1a2ba2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ > **A reproducible, self-documenting, and recoverable OpenCode development workstation** — configuration, agent skills, MCP stack, and an MCP server that clones the entire setup onto any Linux machine (locally or over SSH). +**Homepage & remote MCP:** https://opencode-workbench.simonmak.com · `POST https://opencode-workbench.simonmak.com/mcp` + ## Purpose This repository is the **single source of truth** for a complete cloud development workstation. Everything needed to rebuild from scratch is versioned here: diff --git a/connector.json b/connector.json index 7c39335..71e72ce 100644 --- a/connector.json +++ b/connector.json @@ -3,7 +3,7 @@ "name": "opencode-workbench", "title": "OpenCode Workbench", "description": "Clones the OpenCode workbench installation onto Linux machines, locally or over SSH.", - "version": "2.0.0", + "version": "2.1.0", "transport": { "type": "stdio", "command": "npx", diff --git a/context7.json b/context7.json index d5ac9f7..6c8e715 100644 --- a/context7.json +++ b/context7.json @@ -1,7 +1,7 @@ { "$schema": "https://context7.com/schema/context7.json", "projectTitle": "OpenCode Workbench (opencode-workbench)", - "description": "Provisions and clones the OpenCode workbench onto Linux machines — locally or over SSH — reproducing the config, MCP servers (35), agent skills (45), plugins and Docker tooling. Exposes 9 tools over stdio, including bootstrap_host for one-call provisioning.", + "description": "Provisions and clones the OpenCode workbench onto Linux machines — locally or over SSH — reproducing the config, MCP servers (35), agent skills (45), plugins and Docker tooling. Exposes 11 tools over stdio, including bootstrap_host for one-call provisioning and remove_component/update_component for the lifecycle.", "folders": [ "mcp-server", "docs", diff --git a/docs/CHANGELOG_WORKBENCH.md b/docs/CHANGELOG_WORKBENCH.md index 843d8b7..78375c7 100644 --- a/docs/CHANGELOG_WORKBENCH.md +++ b/docs/CHANGELOG_WORKBENCH.md @@ -3,6 +3,24 @@ > All notable changes to the workbench configuration. > Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## [2026-10-03] — v2.1.0 + +### Added +- **Web introduction page** at `/` on the hosted connector — `public/index.html` + (self-contained, responsive, no build step) plus `public/favicon.svg`. Fixes + the 404 at `https://opencode-workbench.simonmak.com/`. +- **`remove_component`** and **`update_component`** MCP tools (consent-gated, + value-blind, idempotent) — completing the install → update → remove lifecycle + (raises TDQS server coherence "completeness"). + +### Changed +- TDQS description pass across tools (behavioral transparency for the mutating + tools: exactly what is overwritten/destroyed, auth prerequisites, duration). +- Tool count 9 → 11 across `README`, `AGENTS.md`, `mcp-server/README.md`, + `docs/architecture/clone-mcp.md`, `docs/architecture/opencode-runtime-snapshot.md`, + `llms.txt`, `context7.json`, `glama.json`. +- `vercel.json`: `GET /` rewrite + `X-Content-Type-Options` / `Referrer-Policy` headers. + ## [2026-10-03] — v2.0.0 ### Breaking diff --git a/docs/architecture/clone-mcp.md b/docs/architecture/clone-mcp.md index 3798396..7eafcae 100644 --- a/docs/architecture/clone-mcp.md +++ b/docs/architecture/clone-mcp.md @@ -42,6 +42,8 @@ mcp-server/src/ | `plan_clone` | Diff the target against the profile → steps (with privileged-command previews). | | `apply_clone` | Consent-gated: without `confirm:true` returns the plan; with it, clones + installs. | | `install_component` | Install one component by id (also `confirm`-gated). | +| `remove_component` | Uninstall one component by id (bounded subset; `confirm`-gated). | +| `update_component` | Update/upgrade one component in place (`confirm`-gated). | | `verify_clone` | Re-check config, env template, and every component. | | `list_required_credentials` | Value-blind: which keys are missing and how to acquire each. | | `run_auth_flow` | Emit-and-verify acquisition guidance for one credential. | diff --git a/docs/architecture/opencode-runtime-snapshot.md b/docs/architecture/opencode-runtime-snapshot.md index a0ef6d8..e848f28 100644 --- a/docs/architecture/opencode-runtime-snapshot.md +++ b/docs/architecture/opencode-runtime-snapshot.md @@ -35,9 +35,9 @@ Notes: ## MCP Tool Surface -The server exposes **9 tools**: `get_workbench_info`, `inspect_target`, `plan_clone`, -`apply_clone`, `verify_clone`, `install_component`, `list_required_credentials`, -`run_auth_flow`, `bootstrap_host`. +The server exposes **11 tools**: `get_workbench_info`, `inspect_target`, `plan_clone`, +`apply_clone`, `verify_clone`, `install_component`, `remove_component`, +`update_component`, `list_required_credentials`, `run_auth_flow`, `bootstrap_host`. ## Agent Configuration diff --git a/glama.json b/glama.json index 130603a..75c3e1b 100644 --- a/glama.json +++ b/glama.json @@ -2,7 +2,7 @@ "$schema": "https://glama.ai/mcp/schemas/server.json", "maintainers": ["simonmak-ascent"], "name": "opencode-workbench", - "description": "One-command reproducibility for your AI-agent workstation. Nine tools let an agent inspect, plan, apply, and verify an idempotent, consent-gated clone of the full OpenCode stack — the OpenCode CLI, 35 MCP servers, agent skills, plugins, and Docker infrastructure — onto any Linux machine, over SSH or locally, with zero secrets ever written. bootstrap_host does it end-to-end, including a kernel-up platform scan and a recorded OpenCode version pin.", + "description": "One-command reproducibility for your AI-agent workstation. Eleven tools let an agent inspect, plan, apply, verify, update, and remove an idempotent, consent-gated clone of the full OpenCode stack — the OpenCode CLI, 35 MCP servers, agent skills, plugins, and Docker infrastructure — onto any Linux machine, over SSH or locally, with zero secrets ever written. bootstrap_host does it end-to-end, including a kernel-up platform scan and a recorded OpenCode version pin.", "repository": "https://github.com/simonmak-ascent/opencode-workbench", "license": "MIT", "runtime": "node", diff --git a/llms.txt b/llms.txt index 7c5899e..bf9076a 100644 --- a/llms.txt +++ b/llms.txt @@ -1,19 +1,20 @@ # OpenCode Workbench (opencode-workbench) > Open-source (MIT) MCP server + connector that provisions and clones the OpenCode -> workbench onto Linux machines — locally or over SSH. Nine tools cover a +> workbench onto Linux machines — locally or over SSH. Eleven tools cover a > kernel-up platform scan, a one-call bootstrap, an idempotent, consent-gated -> clone, and value-blind credential guidance. No secret values are ever read, -> transmitted, or written. +> clone, the install/update/remove lifecycle, and value-blind credential guidance. +> No secret values are ever read, transmitted, or written. ## Connect - Local stdio server: `npx -y @simonmak-ascent/opencode-workbench` - Hosted (Streamable HTTP): `https://opencode-workbench.simonmak.com/mcp` +- Homepage: https://opencode-workbench.simonmak.com - Glama: https://glama.ai/mcp/servers/simonmak-ascent/opencode-workbench - Context7 library ID: `/simonmak-ascent/opencode-workbench` -## MCP tools (9) +## MCP tools (11) - `get_workbench_info` — describe this server: repo, components by tier, optional MCP add-ons - `inspect_target` — probe a machine: OS, arch, package manager, Node, Docker, OpenCode, home and npm-global paths @@ -21,6 +22,8 @@ - `apply_clone` — consent-gated: without `confirm:true` returns the plan; with it, clones + installs the profile (idempotent) - `verify_clone` — re-check config, env template, and every component's detection - `install_component` — install a single component by id (also `confirm`-gated) +- `remove_component` — uninstall a single component by id (bounded subset; `confirm`-gated) +- `update_component` — update/upgrade a single component in place (`confirm`-gated) - `list_required_credentials` — value-blind: which credentials are missing and how to acquire each - `run_auth_flow` — emit-and-verify acquisition guidance for one credential - `bootstrap_host` — one-call provisioning: kernel-up platform scan + dry-run upgrade plan, latest-stable OpenCode with recorded version, apply + verify; `help:true` documents every parameter without touching the target diff --git a/mcp-server/README.md b/mcp-server/README.md index 184a092..d6fc459 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -38,7 +38,9 @@ From a checkout, use the local build instead: | `inspect_target` | OS/arch/pkg-manager/Node/Docker/OpenCode/home/npm paths. | | `plan_clone` | Diff the target against the profile → steps to install / present / manual. | | `apply_clone` | Clone the profile + install missing components + write rendered config. Idempotent. | -| `install_component` | Install a single component by id. | +| `install_component` | Install a single component by id (consent-gated). | +| `remove_component` | Uninstall a single component by id (bounded subset; consent-gated). | +| `update_component` | Update/upgrade a single component in place (consent-gated). | | `verify_clone` | Re-check config, env template, and every component. | | `bootstrap_host` | One-call provisioning: kernel-up platform scan + dry-run upgrade plan, latest-stable OpenCode + version pin, apply, verify; `help:true` documents parameters without touching the target. | | `list_required_credentials` | Value-blind: which credentials are missing and how to acquire each. | diff --git a/mcp-server/src/clone.ts b/mcp-server/src/clone.ts index 3c188bc..7038f3d 100644 --- a/mcp-server/src/clone.ts +++ b/mcp-server/src/clone.ts @@ -6,6 +6,7 @@ import { PREAMBLE, componentById, defaultComponentIds, + uninstallScript, type Component, } from "./components.js"; import { @@ -302,3 +303,68 @@ export async function verify(target: Target, options: CloneOptions = {}): Promis missing: components.filter((c) => !c.present).map((c) => c.id), }; } + +export interface ComponentActionResult { + target: string; + id: string; + action: "removed" | "updated" | "absent" | "manual"; + code: number; + output: string; +} + +/** + * Remove one component from a target (reverse of install_component). Idempotent: + * an already-absent component returns `absent`. Components without an automated + * uninstall (system packages) return `manual`. Never touches secret values. + */ +export async function removeComponent( + target: Target, + id: string, + options: CloneOptions = {}, +): Promise { + const c = componentById(id); + if (!c) throw new Error(`unknown component '${id}'`); + const script = uninstallScript(id); + const present = await detect(target, [id]); + if (options.dryRun) { + return { target: target.label, id, action: present[id] ? "removed" : "absent", code: 0, output: "(dry run)" }; + } + if (present[id] !== true) { + return { target: target.label, id, action: "absent", code: 0, output: "already absent" }; + } + if (!script) { + return { target: target.label, id, action: "manual", code: 0, output: "no automated uninstall; remove manually" }; + } + const res = await target.run(withEnv(script, options.workspace || ""), { timeoutMs: 600_000 }); + return { + target: target.label, + id, + action: "removed", + code: res.code, + output: `${(res.stdout + res.stderr).trim().slice(0, 1200)}`, + }; +} + +/** + * Update (re-install / upgrade) one component in place. Idempotent: runs the + * component's install script, which upgrades to the current version. + */ +export async function updateComponent( + target: Target, + id: string, + options: CloneOptions = {}, +): Promise { + const c = componentById(id); + if (!c) throw new Error(`unknown component '${id}'`); + if (options.dryRun) { + return { target: target.label, id, action: "updated", code: 0, output: "(dry run)" }; + } + const res = await target.run(withEnv(c.install, options.workspace || ""), { timeoutMs: 600_000 }); + return { + target: target.label, + id, + action: "updated", + code: res.code, + output: `${(res.stdout + res.stderr).trim().slice(0, 1200)}`, + }; +} diff --git a/mcp-server/src/components.ts b/mcp-server/src/components.ts index ab42982..3256eae 100644 --- a/mcp-server/src/components.ts +++ b/mcp-server/src/components.ts @@ -340,3 +340,30 @@ export function componentById(id: string): Component | undefined { export function defaultComponentIds(): string[] { return COMPONENTS.filter((c) => c.tier === "required" || c.tier === "core").map((c) => c.id); } + +/** + * Bounded, idempotent uninstall scripts for the components whose removal is + * automated. Components absent from this map cannot be removed automatically + * (system packages such as git/curl/node) and are reported as `manual`. + */ +export const UNINSTALL_SCRIPTS: Record = { + pnpm: `command -v npm >/dev/null 2>&1 && npm rm -g pnpm >/dev/null 2>&1 || true`, + opencode: `rm -rf "$WB_HOME/.opencode" "$WB_HOME/.local/bin/opencode" +command -v npm >/dev/null 2>&1 && npm rm -g opencode-ai >/dev/null 2>&1 || true`, + uv: `rm -f "$WB_HOME/.local/bin/uv" "$WB_HOME/.local/bin/uvx"`, + "research-mcps": `command -v npm >/dev/null 2>&1 && npm rm -g --silent @simonmak-ascent/primary-sources-mcp >/dev/null 2>&1 || true`, + "npm-mcps": `command -v npm >/dev/null 2>&1 || { warn "npm missing"; exit 1; } +${CORE_NPM_MCPS.map((p) => ` npm rm -g --silent ${p} >/dev/null 2>&1 || true`).join("\n")}`, + "vendored-mcps": `rm -rf "$WB_HOME/.local/bin/perplexity-agent-mcp" "$WB_HOME/.local/bin/browserless-mcp"`, + "github-mcp": `rm -f "$WB_HOME/.local/bin/github-mcp-server"`, + skills: `rm -rf "$WB_HOME/.config/opencode/skills"`, + plugins: `rm -rf "$WB_HOME/.config/opencode/plugins"`, + "docker-containers": `command -v docker >/dev/null 2>&1 || { warn "docker missing"; exit 1; } +docker rm -f pg-memory browserless >/dev/null 2>&1 || true`, + "playwright-browsers": `rm -rf "$WB_HOME/.cache/ms-playwright"`, +}; + +/** Uninstall script for a component id, or undefined when removal is manual. */ +export function uninstallScript(id: string): string | undefined { + return UNINSTALL_SCRIPTS[id]; +} diff --git a/mcp-server/src/tools.ts b/mcp-server/src/tools.ts index 43a2e65..3a0d7e4 100644 --- a/mcp-server/src/tools.ts +++ b/mcp-server/src/tools.ts @@ -4,7 +4,9 @@ import { apply, inspect, plan, + removeComponent, targetFromSpec, + updateComponent, verify, type CloneOptions, } from "./clone.js"; @@ -268,7 +270,7 @@ export function registerTools(server: McpServer): void { { title: "Apply a Workbench clone", description: - "Install the Workbench profile on a target: clone the profile repo and install missing components (repo, OpenCode CLI, Node/pnpm, npm/vendored/research MCPs, skills, plugins, optional Docker), writing a rendered `opencode.json` and an empty, names-only `~/.env.workbench` (mode 600). Idempotent — present components are skipped. Consent-gated: without `confirm:true` it returns the plan and changes nothing. Use it for a component subset or per-component control on an already-provisioned host; for a first-time provision use bootstrap_host, and do not call both for the same host and change.", + "Install the Workbench profile on a target: clone the profile repo and install missing components (repo, OpenCode CLI, Node/pnpm, npm/vendored/research MCPs, skills, plugins, optional Docker). Writes and OVERWRITES `/.config/opencode/opencode.json` and copies `AGENTS.md`; on an existing clone it runs `git reset --hard origin/main` (discarding local edits in the profile repo); global npm installs can take minutes and require write permission (plus SSH access for `mode:ssh`). Writes an empty, names-only `~/.env.workbench` (mode 600) and never secret values. Idempotent — present components are skipped. Consent-gated: without `confirm:true` it returns the plan and changes nothing. Use it for a component subset or per-component control on an already-provisioned host; for a first-time provision use bootstrap_host, and do not call both for the same host and change.", inputSchema: { target: targetShape, confirm: z.boolean().optional().describe("Set true to actually install. When absent, the call returns a plan and makes no changes."), @@ -323,7 +325,7 @@ export function registerTools(server: McpServer): void { { title: "Install one Workbench component", description: - "Install a single Workbench component by id (e.g. `node`, `opencode`, `npm-mcps`, `skills`) on a target; `component` must be an id from get_workbench_info. Idempotent and consent-gated: without `confirm:true` it returns the plan. Use it for one targeted component; use apply_clone for a component subset, or bootstrap_host for a first-time end-to-end provision — do not combine them for the same host and change.", + "Install a single Workbench component by id (e.g. `node`, `opencode`, `npm-mcps`, `skills`) on a target; `component` must be an id from get_workbench_info. Overwrites that component's files when present (e.g. `skills`/`plugins` replace the copies under `/.config/opencode`); global installs need write permission and can take minutes. Idempotent and consent-gated: without `confirm:true` it returns the plan. Use it for one targeted component; use apply_clone for a component subset, or bootstrap_host for a first-time end-to-end provision — do not combine them for the same host and change.", inputSchema: { target: targetShape, component: z.string().describe("Component id from get_workbench_info."), @@ -353,6 +355,79 @@ export function registerTools(server: McpServer): void { }, ); + const componentActionOutput = z.object({ + target: z.string().describe("Label of the target."), + id: z.string().describe("Component id acted on."), + action: z.enum(["removed", "updated", "absent", "manual"]).describe("What happened: removed, updated, already absent, or manual (no automated uninstall)."), + code: z.number().describe("Exit code of the action."), + output: z.string().describe("Captured output (truncated)."), + requiresConfirmation: z.boolean().optional().describe("True when the call returned a preview without acting; re-call with confirm:true."), + }); + + server.registerTool( + "remove_component", + { + title: "Remove one Workbench component", + description: + "Uninstall a single Workbench component by id from a target — the inverse of install_component — for a bounded, documented subset (`opencode`, `pnpm`, `uv`, `npm-mcps`, `vendored-mcps`, `research-mcps`, `github-mcp`, `skills`, `plugins`, `docker-containers`, `playwright-browsers`); `component` must be an id from get_workbench_info. Idempotent: an already-absent component reports `absent`; components with no automated uninstall (system packages such as git/curl/node) report `manual` and require manual removal. Consent-gated and destructive: without `confirm:true` it returns a preview and changes nothing. Removes only that component's files — it does not delete `opencode.json` or the profile repo. Use it to tear down one component; to remove several, call it per id.", + inputSchema: { + target: targetShape, + component: z.string().describe("Component id from get_workbench_info."), + workspace: z.string().optional().describe("Profile repo directory (used by components whose removal needs it)."), + confirm: z.boolean().optional().describe("Set true to actually remove. When absent, the call returns a preview and makes no changes."), + dryRun: z.boolean().optional().describe("Report the action without changing anything."), + }, + outputSchema: componentActionOutput, + annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, + }, + async ({ target, component, workspace, confirm, dryRun }: { target: TargetArg; component: string; workspace?: string; confirm?: boolean; dryRun?: boolean }) => { + try { + const c = componentById(component); + if (!c) throw new Error(`unknown component '${component}'`); + const spec = targetFromSpec(toSpec(target)); + if (confirm !== true) { + const preview = await removeComponent(spec, component, { workspace, dryRun: true }); + return structured({ ...preview, requiresConfirmation: true }); + } + return structured(await removeComponent(spec, component, { workspace, dryRun })); + } catch (e) { + return fail("remove_component failed", e); + } + }, + ); + + server.registerTool( + "update_component", + { + title: "Update one Workbench component", + description: + "Update (re-install / upgrade in place) a single Workbench component by id on a target by re-running its install script, which fetches the current version (e.g. `opencode` re-runs the official installer; `npm-mcps` reinstalls the latest globals). Idempotent and consent-gated: without `confirm:true` it returns a preview and changes nothing. Requires write permission; global installs can take minutes. Use it to refresh one component; use apply_clone to reconcile the whole profile, or bootstrap_host to also upgrade the platform and pin the OpenCode version.", + inputSchema: { + target: targetShape, + component: z.string().describe("Component id from get_workbench_info."), + workspace: z.string().optional().describe("Profile repo directory (used by components whose update needs it)."), + confirm: z.boolean().optional().describe("Set true to actually update. When absent, the call returns a preview and makes no changes."), + dryRun: z.boolean().optional().describe("Report the action without changing anything."), + }, + outputSchema: componentActionOutput, + annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, + }, + async ({ target, component, workspace, confirm, dryRun }: { target: TargetArg; component: string; workspace?: string; confirm?: boolean; dryRun?: boolean }) => { + try { + const c = componentById(component); + if (!c) throw new Error(`unknown component '${component}'`); + const spec = targetFromSpec(toSpec(target)); + if (confirm !== true) { + const preview = await updateComponent(spec, component, { workspace, dryRun: true }); + return structured({ ...preview, requiresConfirmation: true }); + } + return structured(await updateComponent(spec, component, { workspace, dryRun })); + } catch (e) { + return fail("update_component failed", e); + } + }, + ); + server.registerTool( "list_required_credentials", { @@ -511,7 +586,7 @@ export function registerTools(server: McpServer): void { { title: "Bootstrap a bare Linux host", description: - "Provision a bare Linux target end-to-end in one call: scan the platform from the kernel up and return a dry-run upgrade plan, install the latest stable OpenCode and record the resolved version, apply the VDD profile config, and verify parity. Pass `help:true` for full parameter documentation without contacting the target. Consent-gated: without `confirm:true` it returns a plan and changes nothing. Set `upgrade:true` (root/sudo) to run the platform upgrade; default is plan-only. Never reads or transmits secret values. Use it for a first-time provision of a bare host — do NOT also call inspect_target, plan_clone, apply_clone, or install_component for the same host and change; use those granular tools instead when you need step-by-step control of an existing profile.", + "Provision a bare Linux target end-to-end in one call: scan the platform from the kernel up and return a dry-run upgrade plan, install the latest stable OpenCode and record the resolved version, apply the VDD profile config, and verify parity. Requires SSH access for `mode:ssh`; a full run can take many minutes (platform upgrade + installs). Pass `help:true` for full parameter documentation without contacting the target. Consent-gated: without `confirm:true` it returns a plan and changes nothing. Set `upgrade:true` (requires root/sudo) to run the platform upgrade; default is plan-only. Never reads or transmits secret values. Use it for a first-time provision of a bare host — do NOT also call inspect_target, plan_clone, apply_clone, or install_component for the same host and change; use those granular tools instead when you need step-by-step control of an existing profile.", inputSchema: { target: targetShape.optional(), help: z.boolean().optional().describe("Return parameter documentation and skip all target access."), diff --git a/mcp-server/test/component-actions.test.ts b/mcp-server/test/component-actions.test.ts new file mode 100644 index 0000000..797d7c9 --- /dev/null +++ b/mcp-server/test/component-actions.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { COMPONENTS, UNINSTALL_SCRIPTS, uninstallScript } from "../src/components"; + +describe("component uninstall coverage", () => { + const ids = new Set(COMPONENTS.map((c) => c.id)); + + it("only names real components", () => { + for (const id of Object.keys(UNINSTALL_SCRIPTS)) { + expect(ids.has(id), `unknown component in UNINSTALL_SCRIPTS: ${id}`).toBe(true); + } + }); + + it("covers the documented teardown subset", () => { + for (const id of [ + "opencode", + "pnpm", + "uv", + "npm-mcps", + "vendored-mcps", + "research-mcps", + "github-mcp", + "skills", + "plugins", + "docker-containers", + "playwright-browsers", + ]) { + expect(typeof uninstallScript(id), `missing uninstall for ${id}`).toBe("string"); + } + }); + + it("returns undefined for non-automated components", () => { + for (const id of ["git", "curl", "node"]) { + expect(uninstallScript(id)).toBeUndefined(); + } + }); + + it("every uninstall script avoids secret values and status files", () => { + for (const [id, script] of Object.entries(UNINSTALL_SCRIPTS)) { + expect(script).not.toMatch(/\.env\.workbench/); + expect(script, `${id} must not delete opencode.json`).not.toMatch(/opencode\.json/); + } + }); +}); diff --git a/mcp-server/test/server.test.ts b/mcp-server/test/server.test.ts index 5013bb0..18841a0 100644 --- a/mcp-server/test/server.test.ts +++ b/mcp-server/test/server.test.ts @@ -27,7 +27,9 @@ describe("opencode-workbench server", () => { "install_component", "list_required_credentials", "plan_clone", + "remove_component", "run_auth_flow", + "update_component", "verify_clone", ]); await client.close(); @@ -89,4 +91,30 @@ describe("opencode-workbench server", () => { expect(Array.isArray(plan.toInstall)).toBe(true); await client.close(); }); + + it("previews remove_component without acting", async () => { + const client = await connect(); + const res = await client.callTool({ + name: "remove_component", + arguments: { target: { mode: "local" }, component: "skills" }, + }); + expect(res.isError).toBeFalsy(); + const out = res.structuredContent as { requiresConfirmation?: boolean; action: string }; + expect(out.requiresConfirmation).toBe(true); + expect(["removed", "absent", "manual"]).toContain(out.action); + await client.close(); + }); + + it("previews update_component without acting", async () => { + const client = await connect(); + const res = await client.callTool({ + name: "update_component", + arguments: { target: { mode: "local" }, component: "skills" }, + }); + expect(res.isError).toBeFalsy(); + const out = res.structuredContent as { requiresConfirmation?: boolean; action: string }; + expect(out.requiresConfirmation).toBe(true); + expect(out.action).toBe("updated"); + await client.close(); + }); }); diff --git a/package.json b/package.json index 0ef1973..a3929c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@simonmak-ascent/opencode-workbench", "mcpName": "io.github.simonmak-ascent/opencode-workbench", - "version": "2.0.0", + "version": "2.1.0", "description": "MCP server and connector that clones the OpenCode workbench configuration onto Linux machines (locally or over SSH).", "license": "MIT", "type": "module", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..86f2dfc --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e9eae6c --- /dev/null +++ b/public/index.html @@ -0,0 +1,246 @@ + + + + + + OpenCode Workbench — MCP server that provisions a Linux dev workstation + + + + + + + + + + + + +
+
+
Model Context Protocol server & connector
+

Provision a reproducible OpenCode workstation on any Linux box.

+

+ opencode-workbench is an MCP server that turns a bare Linux machine — local or + remote over SSH — into a ready OpenCode development workstation: it scans the platform, installs + the latest stable OpenCode, applies the full profile (MCP servers, agent skills, plugins, config), + and verifies the result. Secrets are never read, transmitted, or written. +

+ +

+ npm + Official MCP Registry + Glama + Context7 + Streamable HTTP + MIT +

+
+ +
+

11 tools over one MCP surface

+

Read-only discovery and planning; consent-gated mutations; value-blind credential handling.

+
+ + + + + + + + + + + + + + + + + +
ToolWhat it doesMode
get_workbench_infoRepo, components by tier, optional MCP add-ons.read-only
inspect_targetOS, kernel, arch, package manager, Node, Docker, OpenCode, credential names.read-only
plan_cloneDiff a target against the profile → install / present / manual steps.read-only
apply_cloneClone the profile + install missing components; consent-gated.mutating
install_componentInstall one component by id; consent-gated.mutating
remove_componentUninstall one component by id (bounded subset); consent-gated.mutating
update_componentUpdate/upgrade one component in place; consent-gated.mutating
verify_cloneRe-check config, env template, and every component.read-only
list_required_credentialsWhich credentials are missing and how to acquire each (value-blind).read-only
run_auth_flowEmit-and-verify acquisition guidance for one credential.read-only
bootstrap_hostOne-call end-to-end provision: platform scan + upgrade plan, latest-stable OpenCode with recorded version, apply, verify; help:true.mutating
+
+
+ +
+

Quick start

+
+
+

1 · Local stdio

+

Run without installing, then point any MCP client at it.

+
npx -y @simonmak-ascent/opencode-workbench
+
+
+

2 · Remote (Streamable HTTP)

+

Use the hosted connector — no local install.

+
https://opencode-workbench.simonmak.com/mcp
+
+
+

Register with OpenCode (or any MCP client)

+
{
+  "mcp": {
+    "opencode-workbench": {
+      "type": "local",
+      "command": ["npx", "-y", "@simonmak-ascent/opencode-workbench"],
+      "enabled": true,
+      "timeout": 600000
+    }
+  }
+}
+

Then drive a clone

+
inspect_target  →  plan_clone  →  apply_clone { confirm: true }  →  verify_clone
+
+# or one call, end to end:
+bootstrap_host  { target: { mode: "ssh", host: "box", user: "me" }, confirm: true }
+
+# see every parameter:
+bootstrap_host  { help: true }
+
+ +
+

Security posture

+
+

Secret-value-free

The connector never reads, transmits, or writes secret values. It writes an empty ~/.env.workbench (mode 600) listing only the variable names to fill in.

+

Consent-gated mutations

Every install/apply/remove/update returns a plan first; nothing changes until you pass confirm: true.

+

Value-blind credentials

Credential tools check only whether an env var is set and return acquisition guidance — never the value.

+

Scanned

The repository is public and gated by gitleaks and GitHub secret scanning on every push.

+
+
+ +
+

Where to find it

+

+ GitHub repository · + npm package · + Official MCP Registry · + Glama · + Context7 +

+
+
+ + + + diff --git a/server.json b/server.json index ca7ae4b..059bd6c 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.github.simonmak-ascent/opencode-workbench", "description": "Provision an OpenCode workbench and MCP stack on any Linux box, local or over SSH.", - "version": "2.0.0", + "version": "2.1.0", "repository": { "url": "https://github.com/simonmak-ascent/opencode-workbench", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@simonmak-ascent/opencode-workbench", - "version": "2.0.0", + "version": "2.1.0", "transport": { "type": "stdio" } } ], diff --git a/vercel.json b/vercel.json index 117f01e..b6c60ec 100644 --- a/vercel.json +++ b/vercel.json @@ -6,6 +6,16 @@ }, "rewrites": [ { "source": "/.well-known/glama.json", "destination": "/api/glama" }, - { "source": "/mcp", "destination": "/api/mcp" } + { "source": "/mcp", "destination": "/api/mcp" }, + { "source": "/", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } ] }