@tanqory/dev-mcp 0.1.0-alpha.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,24 +17,39 @@ theme, only after you confirm a plan.
17
17
 
18
18
  ## Setup
19
19
 
20
- A theme from `npm create tanqory-theme` is already configured. To configure one by hand, run the server from the
21
- theme folder:
20
+ A theme from `npm create tanqory-theme` is already configured: it has this package as a devDependency and starts it
21
+ with `node` from `node_modules`. Start it the same way when you configure a theme by hand (`npm install -D
22
+ @tanqory/dev-mcp` first). Avoid `npx` here: an agent host that starts its first turn while MCP servers are still
23
+ connecting (Codex does) sees no tools while `npx` resolves the package (1.4–7.4 s, against about 0.25 s for `node`).
22
24
 
23
25
  ```jsonc
24
26
  // .mcp.json (Claude Code)
25
- { "mcpServers": { "tanqory": { "command": "npx", "args": ["-y", "@tanqory/cli", "mcp"] } } }
27
+ {
28
+ "mcpServers": {
29
+ "tanqory": {
30
+ "command": "node",
31
+ "args": ["node_modules/@tanqory/dev-mcp/bin/tanqory-dev-mcp.mjs"],
32
+ },
33
+ },
34
+ }
26
35
  ```
27
36
 
28
37
  ```toml
29
- # .codex/config.toml (Codex)
38
+ # .codex/config.toml (Codex; read only in a trusted folder)
30
39
  [mcp_servers.tanqory]
31
- command = "npx"
32
- args = ["-y", "@tanqory/cli", "mcp"]
40
+ command = "node"
41
+ args = ["node_modules/@tanqory/dev-mcp/bin/tanqory-dev-mcp.mjs"]
42
+ tool_timeout_sec = 900 # check_theme takes minutes
33
43
  ```
34
44
 
35
- `tanqory mcp` (from `@tanqory/cli`) starts this server and passes itself as the CLI to call. To run the server on
36
- its own, use `npx -y @tanqory/dev-mcp`. It then looks for the CLI in the theme's `node_modules/.bin`, then on
37
- `PATH`. Node 20 or newer.
45
+ The server looks for the CLI in the theme's `node_modules/.bin`, then on `PATH`. `tanqory mcp` (from
46
+ `@tanqory/cli`) also starts this server, passing itself as the CLI to call. Node 20 or newer.
47
+
48
+ **Sandboxed agent shells.** Some hosts run each shell command in an OS sandbox (Codex). There, headless Chromium
49
+ cannot start, and a command may not signal a process another command started. The server the host configured runs
50
+ outside that sandbox, so use its tools (`start_dev_server`, `screenshot`, `check_theme`, `stop_dev_server`). A copy
51
+ started from a sandboxed shell (`CODEX_SANDBOX` set) refuses the browser and check tools at once with
52
+ `sandboxed_shell`, instead of failing minutes later.
38
53
 
39
54
  ## Tools
40
55
 
@@ -53,7 +68,9 @@ next.
53
68
  | `validate_manifest` | Checks `dist/abi/manifest.json` with the theme's own `validateManifest` plus the JCS byte rule. Problems are traced to the `src/` file and line that declares them. |
54
69
  | `check_theme {tier?}` | Runs `tanqory theme check --json`. The tiers are security (the 9 ABI suites), storefront and budgets. Each failure carries `tier`, `checkId`, `message` and `hint`, plus `file:line` when known. |
55
70
  | `run_storefront_checks` | Runs `tanqory theme check --tier storefront --json`. |
56
- | `dev_server_status` | Reads `.tanqory/dev-server.json` and `GET /__tq/status`, then reports the URL, the store, the mode and the last build's errors. It never starts the server for you. |
71
+ | `start_dev_server {mock?, port?}` | Starts `tanqory theme dev` (output in `.tanqory/dev-server.log`) and waits until it serves. Returns the URL, the pid and the last build. Already running: returns that one. A server that exits first returns its own error. Dev servers it started stop when the MCP server shuts down. |
72
+ | `stop_dev_server` | Runs `tanqory theme dev --stop`: `POST /__tq/stop` with the token in `.tanqory/dev-server.json`, else a signal to its verified pid. It never stops a process by name. |
73
+ | `dev_server_status` | Reads `.tanqory/dev-server.json` and `GET /__tq/status`, then reports the URL, the store, the mode and the last build's errors. It never starts or stops the server. |
57
74
  | `get_page {path, locale?, market?, viewport?, maxHtmlChars?}` | Gets the server-rendered HTML from `GET /__tq/page`. It also loads the page in headless Chromium and collects console errors. |
58
75
  | `screenshot {path, viewport, locale?, market?, fullPage?}` | Returns a PNG, as an MCP image block, from local Playwright. |
59
76
  | `list_dev_stores` | Runs `tanqory stores --json` and reports the store this theme is linked to. |
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // The Tanqory theme-development MCP server on stdio. Configure your agent host to run `npx -y @tanqory/dev-mcp`
3
- // (or `npx -y @tanqory/cli mcp`) in the theme folder.
2
+ // The Tanqory theme-development MCP server on stdio. Configure your agent host to run it from the theme folder with
3
+ // `node node_modules/@tanqory/dev-mcp/bin/tanqory-dev-mcp.mjs` (a theme devDependency; no npx at startup).
4
4
  import { main } from '../src/index.mjs'
5
5
 
6
6
  if (process.argv.includes('--version')) {
@@ -105,9 +105,9 @@ Status per check: `pass`, `fail`, `warn` (reported, never fails the tier), `not_
105
105
 
106
106
  **What is approximated (exactly).** Lighthouse is not run: its own navigation would bypass the gateway
107
107
  that answers every request in-process. The runner measures in its own Chromium instead:
108
- - mobile 390×844 DPR 2; CPU slowed 4× (`Emulation.setCPUThrottlingRate`); every response delayed by
109
- 150 ms + (bytes on the wire × 8 / 1638.4 kbps) by the gateway, text counted gzipped (Lighthouse's
110
- "slow 4G" figures applied per response, not a simulated connection pool);
108
+ - mobile 390×844 DPR 2; CPU slowed 4× relative to the **uncontended** host (see "Busy hosts" below);
109
+ every response delayed by 150 ms + (bytes on the wire × 8 / 1638.4 kbps) by the gateway, text counted
110
+ gzipped (Lighthouse's "slow 4G" figures applied per response, not a simulated connection pool);
111
111
  - FCP, LCP and CLS from `PerformanceObserver`; TBT = Σ(long task − 50 ms) after FCP over load + 3 s;
112
112
  - **Speed Index is not measured** (no filmstrip); it is taken as (FCP + LCP) / 2;
113
113
  - the score uses Lighthouse's mobile log-normal curves and weights (FCP 10, SI 10, LCP 25, TBT 30, CLS 25);
@@ -116,6 +116,35 @@ that answers every request in-process. The runner measures in its own Chromium i
116
116
 
117
117
  Both scores are labelled `lab-approximation` in the report and are not Lighthouse scores.
118
118
 
119
+ **Busy hosts (host calibration).** `Emulation.setCPUThrottlingRate` slows the renderer relative to the machine
120
+ it runs on, so on a machine whose cores are busy with other work the page is slowed by the throttle *and* by
121
+ the contention: the same theme scored 96–99 on a quiet 6-core host and 76–83 at load 50–240. The runner
122
+ therefore measures and removes the contention instead of letting it count against the theme:
123
+
124
+ 1. *Before* each lab run it times a fixed busy loop in the page's renderer, unthrottled: wall-clock ÷ the main
125
+ thread's CPU time (`Performance.getMetrics` `ThreadTime`) is the host contention, 1.0 on an idle core. The
126
+ CDP rate is `4 ÷ contention`, never below 1.
127
+ 2. *During* the run it records a `toplevel` trace. Σ wall ÷ Σ CPU time (`dur` / `tdur`) of the renderer main
128
+ thread's tasks is the contention the measured page actually saw (the throttle burns CPU on that thread,
129
+ so it cancels out). `effectiveSlowdown = rate × that factor` is the slowdown the page really got.
130
+ 3. A run *decides* the verdict when its effective slowdown is 4× within ×1.25 (3.2–5×). A run slowed more
131
+ than that still proves a **pass** (the theme passed under a harsher device); a run slowed less still
132
+ proves a **fail**. A run off the target is repeated with the rate corrected by its own measured contention,
133
+ kept strictly between the highest rate that slowed the page too little and the lowest that slowed it too
134
+ much (the contention depends on the rate: a heavily throttled renderer burns CPU and is scheduled like a
135
+ batch job, a lightly throttled one stays interactive, so a plain correction can overshoot the band).
136
+ 4. One run per page is enough when it decides and is ≥ 5 points from the budget; otherwise up to four.
137
+ The page score is the lower median of its deciding runs; the check is the minimum over the pages.
138
+ 5. If a page has no deciding run (every run was over-slowed and below 85, or under-slowed and above it) and
139
+ no decided page failed, `budgets:performance` is **`warn`** with `inconclusive: this host was too busy…`
140
+ and the measured factors — not a failure, and not a pass of the theme. Re-run on a quieter machine.
141
+
142
+ On a quiet host the contention is ~1.0, the rate stays 4× and the result is what it was before calibration.
143
+ The busy-loop is not an absolute reference device: throttling stays relative to the machine's own idle
144
+ speed, as before, so a quiet fast machine and a quiet slow one judge a theme exactly as they did. Every run's
145
+ `hostFactor`, `cpuRate`, `windowFactor` and `effectiveSlowdown` is in the check's `evidence.details.pages[].runs`,
146
+ and the maxima in the `performanceScoreMobile` measurement note.
147
+
119
148
  ## 4. Locale-key patterns (the semantic fallback)
120
149
 
121
150
  When no hook is present, a control is a button/link (by ARIA role) whose accessible name equals one of the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "@tanqory/theme-abi": "2.0.0-alpha.14",
3
- "@tanqory/theme-conformance": "0.1.0-alpha.14",
3
+ "@tanqory/theme-conformance": "0.1.0-alpha.15",
4
4
  "@tanqory/theme-kit": "2.0.0-alpha.3",
5
5
  "examples": {
6
6
  "blank": "create-tanqory-theme templates/blank"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanqory/dev-mcp",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local MCP server (stdio) for building Tanqory storefront themes with coding agents: versioned docs and schemas, kit examples, section scaffolding, theme checks, and pages and screenshots of the running dev server.",
5
5
  "keywords": [
6
6
  "tanqory",
@@ -43,7 +43,7 @@
43
43
  "zod": "^3.25.76"
44
44
  },
45
45
  "devDependencies": {
46
- "create-tanqory-theme": "0.2.1"
46
+ "create-tanqory-theme": "0.2.5"
47
47
  },
48
48
  "publishConfig": {
49
49
  "registry": "https://npm.pkg.github.com"
package/src/browser.mjs CHANGED
@@ -8,6 +8,7 @@ import { spawnSync } from 'node:child_process'
8
8
  import { createRequire } from 'node:module'
9
9
  import { dirname, join } from 'node:path'
10
10
  import { pathToFileURL } from 'node:url'
11
+ import { SANDBOX_HINT, SANDBOX_MESSAGE, SANDBOX_SIGNS, inSandboxedShell } from './sandbox.mjs'
11
12
 
12
13
  export const VIEWPORTS = {
13
14
  mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
@@ -15,9 +16,9 @@ export const VIEWPORTS = {
15
16
  }
16
17
 
17
18
  export class BrowserUnavailable extends Error {
18
- constructor(message, hint) {
19
+ constructor(message, hint, code = 'browser_unavailable') {
19
20
  super(message)
20
- this.code = 'browser_unavailable'
21
+ this.code = code
21
22
  this.hint = hint
22
23
  }
23
24
  }
@@ -53,6 +54,7 @@ export class Browser {
53
54
  try {
54
55
  return await pw.chromium.launch(opts)
55
56
  } catch (e) {
57
+ if (inSandboxedShell() || SANDBOX_SIGNS.test(e.message)) throw new BrowserUnavailable(SANDBOX_MESSAGE, SANDBOX_HINT, 'sandboxed_shell')
56
58
  const missing = /Executable doesn't exist|browserType\.launch: .*(not found|ENOENT)/i.test(e.message)
57
59
  if (!missing || opts.executablePath || process.env.TANQORY_MCP_NO_BROWSER_DOWNLOAD === '1')
58
60
  throw new BrowserUnavailable(`Headless Chromium could not start: ${e.message.split('\n')[0]}`, HINT)
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Start and stop `tanqory theme dev` from the MCP server, so an agent never manages the process from its shell.
3
+ *
4
+ * Why it lives here: some agent hosts run every shell command in its own sandbox (Codex's seatbelt), where a
5
+ * command may not signal a process another command started and headless Chromium cannot launch. The MCP server is
6
+ * started by the host itself, outside that sandbox, so a dev server it starts is one it can always see and stop.
7
+ *
8
+ * start: spawn `tanqory theme dev [--mock] [--port n] --json` in the theme root (its output goes to
9
+ * .tanqory/dev-server.log), then wait until .tanqory/dev-server.json names that pid and /__tq/status
10
+ * answers. A server that exits first comes back as its own error (code, message, hint) from the log.
11
+ * stop: `tanqory theme dev --stop --json`: POST /__tq/stop with the record's token, else the pid-verified signal.
12
+ * Never by name.
13
+ * Dev servers this MCP server started are stopped when it shuts down, so a finished agent session leaves none behind.
14
+ */
15
+ import { spawn } from 'node:child_process'
16
+ import { closeSync, mkdirSync, openSync, readFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { CliError, parseEnvelope, resolveCli, runCli } from './cli.mjs'
19
+ import { devGet, readDevServer } from './devserver.mjs'
20
+
21
+ export const DEV_SERVER_LOG = join('.tanqory', 'dev-server.log')
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
23
+
24
+ const tail = (path, lines = 25) => {
25
+ try {
26
+ return readFileSync(path, 'utf8').trim().split('\n').slice(-lines).join('\n')
27
+ } catch {
28
+ return ''
29
+ }
30
+ }
31
+
32
+ export class DevControl {
33
+ constructor({ root, cli, log = () => {} }) {
34
+ this.root = root
35
+ this.cli = cli
36
+ this.log = log
37
+ /** @type {Set<import('node:child_process').ChildProcess>} */
38
+ this.children = new Set()
39
+ }
40
+
41
+ /** @returns {Promise<object>} the running server: { running, alreadyRunning, url, port, mode, store, pid, editor, log } */
42
+ async start({ mock = false, port, timeoutMs = 180_000 } = {}) {
43
+ const root = this.root
44
+ const existing = readDevServer(root)
45
+ if (existing.running) {
46
+ const st = await devGet(existing.record.url, '/__tq/status', { timeoutMs: 5000 }).catch(() => null)
47
+ if (st?.body?.pid === existing.record.pid) {
48
+ const r = existing.record
49
+ return { running: true, alreadyRunning: true, url: r.url, port: r.port, mode: r.mode, store: r.store, pid: r.pid, editor: `${r.url}/__editor`, lastBuild: st.body?.lastBuild }
50
+ }
51
+ }
52
+ const bin = resolveCli({ root, cli: this.cli })
53
+ if (!bin) throw new CliError('cli_not_found', 'The Tanqory CLI (`tanqory`) was not found in this theme.', 'Run `npm install` in the theme (the starters pin @tanqory/cli), then call start_dev_server again.')
54
+ mkdirSync(join(root, '.tanqory'), { recursive: true })
55
+ const logPath = join(root, DEV_SERVER_LOG)
56
+ const fd = openSync(logPath, 'w', 0o600)
57
+ const spawnedAt = Date.now()
58
+ const args = [...bin.args, 'theme', 'dev', ...(mock ? ['--mock'] : []), ...(port ? ['--port', String(port)] : []), '--json']
59
+ let child
60
+ try {
61
+ child = spawn(bin.command, args, { cwd: root, env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, stdio: ['ignore', fd, fd] })
62
+ } finally {
63
+ closeSync(fd)
64
+ }
65
+ this.children.add(child)
66
+ let exited = null
67
+ child.once('exit', (code, signal) => {
68
+ exited = { code, signal }
69
+ this.children.delete(child)
70
+ })
71
+ child.once('error', (e) => {
72
+ exited = { code: null, signal: null, error: e.message }
73
+ this.children.delete(child)
74
+ })
75
+ this.log(`start_dev_server: pid ${child.pid}: ${[bin.command, ...args].join(' ')}`)
76
+
77
+ const until = Date.now() + timeoutMs
78
+ while (Date.now() < until) {
79
+ if (exited) {
80
+ const out = tail(logPath, 60)
81
+ const env = parseEnvelope(out)
82
+ if (env && env.ok === false && env.error) throw new CliError(env.error.code ?? 'dev_server_failed_to_start', `The dev server exited before it was ready: ${env.error.message}`, env.error.hint ?? `See ${DEV_SERVER_LOG}.`, { stderr: tail(logPath) })
83
+ throw new CliError('dev_server_failed_to_start', `The dev server exited (${exited.error ?? `code ${exited.code}${exited.signal ? `, ${exited.signal}` : ''}`}) before it was ready.`, `Its output is in ${DEV_SERVER_LOG}; fix what it says (a build error names file:line), then call start_dev_server again.`, { stderr: tail(logPath) })
84
+ }
85
+ const s = readDevServer(root)
86
+ // ours: the pid we spawned (or, behind a wrapper such as a Windows .cmd shim, a record written after the spawn)
87
+ if (s.running && (s.record.pid === child.pid || Date.parse(s.record.startedAt ?? '') >= spawnedAt - 1000)) {
88
+ const st = await devGet(s.record.url, '/__tq/status', { timeoutMs: 5000 }).catch(() => null)
89
+ if (st && st.status === 200) {
90
+ const r = s.record
91
+ return { running: true, alreadyRunning: false, url: r.url, port: r.port, mode: r.mode, store: r.store, pid: r.pid, editor: `${r.url}/__editor`, lastBuild: st.body?.lastBuild, log: DEV_SERVER_LOG }
92
+ }
93
+ }
94
+ await sleep(400)
95
+ }
96
+ child.kill('SIGTERM')
97
+ throw new CliError('dev_server_start_timeout', `The dev server did not come up within ${Math.round(timeoutMs / 1000)} s and was stopped.`, `See ${DEV_SERVER_LOG}.`, { stderr: tail(logPath) })
98
+ }
99
+
100
+ /** `tanqory theme dev --stop`: the record's own server only. */
101
+ async stop() {
102
+ const env = await runCli(['theme', 'dev', '--stop'], { root: this.root, cli: this.cli, timeoutMs: 60_000 })
103
+ if (!env.ok) throw new CliError(env.error?.code ?? 'dev_server_stop_failed', env.error?.message ?? '`tanqory theme dev --stop` failed.', env.error?.hint, { stderr: env.stderrTail })
104
+ // a child of ours exits on its own once stopped; wait for it so the next start finds the port free
105
+ for (const c of [...this.children]) {
106
+ if (env.data?.pid === c.pid) await Promise.race([new Promise((r) => c.once('exit', r)), sleep(10_000)])
107
+ }
108
+ return env.data ?? { stopped: false }
109
+ }
110
+
111
+ /** On MCP shutdown: stop the dev servers this server started (never any other). */
112
+ async close() {
113
+ const kids = [...this.children]
114
+ if (!kids.length) return
115
+ await this.stop().catch(() => {})
116
+ for (const c of kids) if (c.exitCode === null && c.signalCode === null) c.kill('SIGTERM')
117
+ }
118
+ }
package/src/devserver.mjs CHANGED
@@ -32,7 +32,7 @@ const alive = (pid) => {
32
32
  }
33
33
  }
34
34
 
35
- const NOT_RUNNING_HINT = 'Start it yourself in the background from the theme folder: `npm run dev -- --mock` (offline fixture store; `npm run dev` when signed in with `npx tanqory login`), wait until dev_server_status says it is running, then call this tool again. Stop it later with `npm run dev -- --stop`, never pkill.'
35
+ const NOT_RUNNING_HINT = 'Start it with the start_dev_server tool (mock: true = the offline fixture store), then call this tool again. Shell fallback: `npm run dev -- --mock` in the background. Stop it with stop_dev_server (or `npm run dev -- --stop`), never pkill.'
36
36
 
37
37
  /** The dev-server record, or a reason it cannot be used. Never throws. */
38
38
  export function readDevServer(root) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Sandboxed agent shells. Some hosts run each shell command under an OS sandbox (Codex: macOS seatbelt, with
3
+ * CODEX_SANDBOX set in the command's environment). The build and the dev server run there, but headless Chromium does
4
+ * not: its Mach-port rendezvous is denied (`bootstrap_check_in … Permission denied`), so screenshots and the
5
+ * storefront checks fail or stall. The host starts MCP servers outside that sandbox, so the configured server's tools
6
+ * work; a copy an agent starts from its shell does not. Say so at once.
7
+ */
8
+ export const SANDBOX_SIGNS = /bootstrap_check_in|MachPortRendezvous|Operation not permitted|sandbox_init|seatbelt/i
9
+ export const SANDBOX_MESSAGE = 'You are in a sandboxed shell: headless Chromium cannot start here, so this server (started from that shell) cannot take screenshots or run the checks.'
10
+ export const SANDBOX_HINT = 'Use the `tanqory` MCP tools your host configured (it runs them outside the sandbox). If they are not in your tool list, wait a few seconds and list your tools again. Do not start `tanqory mcp`, Playwright or `theme check` yourself from a sandboxed shell.'
11
+
12
+ /** True when this process was started from a sandboxed agent shell. */
13
+ export const inSandboxedShell = (env = process.env) => Boolean(env.CODEX_SANDBOX)
package/src/server.mjs CHANGED
@@ -13,23 +13,29 @@ import { z } from 'zod'
13
13
  import { Browser, BrowserUnavailable, VIEWPORTS } from './browser.mjs'
14
14
  import { CliError, runCli } from './cli.mjs'
15
15
  import { DOCS, DocCorpus } from './docs.mjs'
16
+ import { DevControl } from './devcontrol.mjs'
16
17
  import { DevServerError, devGet, readDevServer, requireDevServer } from './devserver.mjs'
17
- import { kitCatalogue, kitExamples } from './kit.mjs'
18
18
  import { registerPrompts } from './prompts.mjs'
19
19
  import { LAYOUTS, ScaffoldError, planSection, writeSection } from './scaffold.mjs'
20
20
  import { SCHEMA_KINDS, getSchema } from './schemas.mjs'
21
+ import { SANDBOX_HINT, SANDBOX_MESSAGE, inSandboxedShell } from './sandbox.mjs'
21
22
  import { loadTheme } from './theme.mjs'
22
- import { validateManifestFile } from './manifest.mjs'
23
+
24
+ // Only what a tool call needs is loaded on that call: the agent host waits for `initialize` before it lists the
25
+ // tools, and some hosts (Codex) start the first turn without waiting at all.
26
+ const kitModule = () => import('./kit.mjs')
27
+ const manifestModule = () => import('./manifest.mjs')
23
28
 
24
29
  export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
25
30
 
26
31
  const INSTRUCTIONS = `Tanqory theme development (Theme ABI v2). Use these tools, not only the shell, for every theme change:
27
- 1. Start the dev server in the background and keep it running: \`npm run dev -- --mock\` (plain \`npm run dev\` when signed in). dev_server_status says when it is up.
32
+ 1. Start the dev server with start_dev_server (mock: true = offline fixture store; shell fallback \`npm run dev -- --mock\`). It rebuilds on every save; dev_server_status shows build errors.
28
33
  2. New section: scaffold_section (plan, then confirm with the planDigest). Kit APIs: search_docs / get_kit_example, not node_modules.
29
34
  3. Look at your work: screenshot (desktop and mobile, th and en) and get_page (HTML + console errors).
30
35
  4. Verify with check_theme, not \`npm run check\`: fix every failure it reports, including ones you did not cause, until passed is true.
31
- 5. Stop the dev server with \`npm run dev -- --stop\` (the pid in .tanqory/dev-server.json). Never pkill, killall or a pid found by name.
32
- You are not done until check_theme passes and you have seen the change in a screenshot. Every storefront function (cart, variants, search, filters, markets, forms, SEO, i18n, live edit) comes from @tanqory/theme-kit; the theme owns markup and CSS. This server never calls a model, never takes a store id and never publishes.`
36
+ 5. Stop the dev server with stop_dev_server (shell fallback \`npm run dev -- --stop\`). Never pkill, killall or a pid found by name.
37
+ Never start \`tanqory mcp\`, Playwright or \`theme check\` yourself from a sandboxed shell: Chromium cannot start there; these tools do it.
38
+ You are not done until check_theme returned passed: true and you have seen the change in a screenshot; never say a check is green otherwise. Every storefront function (cart, variants, search, filters, markets, forms, SEO, i18n, live edit) comes from @tanqory/theme-kit; the theme owns markup and CSS. This server never calls a model, never takes a store id and never publishes.`
33
39
 
34
40
  const errorSchema = z.object({ code: z.string(), message: z.string(), hint: z.string().optional() }).passthrough()
35
41
  const base = { ok: z.boolean().describe('false when the tool could not do what was asked; see error'), error: errorSchema.optional() }
@@ -86,7 +92,7 @@ function checkResult(env, requestedTier) {
86
92
  ...shown.map((f) => `- [${f.tier}] ${f.checkId}${f.file ? ` ${f.file}${f.line ? `:${f.line}` : ''}` : ''}${f.route ? ` route ${f.route}` : ''}${f.locale ? ` (${f.locale})` : ''}: ${f.message}${f.hint ? `\n fix: ${f.hint}` : ''}`),
87
93
  ...(failures.length > shown.length ? [`… ${failures.length - shown.length} more (structuredContent.failures is complete)`] : []),
88
94
  ...(passed
89
- ? ['Next: if you have not looked at your change yet, call screenshot on the page you changed (viewport desktop and mobile). Stop the dev server with `npm run dev -- --stop` when you are finished, never pkill.']
95
+ ? ['Next: if you have not looked at your change yet, call screenshot on the page you changed (viewport desktop and mobile). Stop the dev server with stop_dev_server when you are finished, never pkill.']
90
96
  : ['Fix every failure above, including ones you did not cause (look a check up with search_docs {query: "<checkId>"}), then call check_theme again.']),
91
97
  ]
92
98
  return {
@@ -105,6 +111,14 @@ export function createTanqoryMcpServer(options = {}) {
105
111
  const corpus = new DocCorpus(theme)
106
112
  const browser = new Browser(theme.root, log)
107
113
  const cliOpts = { root: theme.root, cli: options.cli }
114
+ const devControl = new DevControl({ root: theme.root, cli: options.cli, log })
115
+ // Started from a sandboxed agent shell (not by the host): Chromium cannot launch here, so the browser and check
116
+ // tools say so at once instead of failing minutes later.
117
+ const sandboxed = inSandboxedShell()
118
+ if (sandboxed) log(`${SANDBOX_MESSAGE} ${SANDBOX_HINT}`)
119
+ const refuseInSandbox = () => {
120
+ if (sandboxed) throw new BrowserUnavailable(SANDBOX_MESSAGE, SANDBOX_HINT, 'sandboxed_shell')
121
+ }
108
122
  const server = new McpServer({ name: 'tanqory', title: 'Tanqory theme development', version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { logging: {} } })
109
123
 
110
124
  const needTheme = () => {
@@ -201,6 +215,7 @@ export function createTanqoryMcpServer(options = {}) {
201
215
  const ex = DOCS.find((x) => x.id === 'kit/exports')
202
216
  const docText = await corpus.read(d)
203
217
  const exportsText = await corpus.read(ex)
218
+ const { kitCatalogue } = await kitModule()
204
219
  return kitCatalogue({ docText: docText?.text, exportsDts: exportsText?.text, installedVersion: theme.versions['@tanqory/theme-kit'], source: docText?.source })
205
220
  }
206
221
 
@@ -247,6 +262,7 @@ export function createTanqoryMcpServer(options = {}) {
247
262
  const c = await catalogue()
248
263
  const api = c.components.find((x) => x.name.toLowerCase() === name.toLowerCase()) ?? null
249
264
  const canonical = api?.name ?? name
265
+ const { kitExamples } = await kitModule()
250
266
  const examples = kitExamples({ name: canonical, themeRoot: theme.root, limit: limit ?? 2 })
251
267
  if (!api && !examples.length) return fail('unknown_kit_name', `"${name}" is not a kit API this server knows and no code uses it.`, 'list_kit_components lists every name; search_docs finds prose.')
252
268
  const text = [
@@ -322,6 +338,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
322
338
  annotations: READ,
323
339
  },
324
340
  guard(async () => {
341
+ const { validateManifestFile } = await manifestModule()
325
342
  const r = await validateManifestFile(needTheme(), theme.versions['@tanqory/theme-abi'])
326
343
  if (r.error) return fail(r.error.code, r.error.message, r.error.hint)
327
344
  const text = r.valid ? `manifest valid (${r.manifestPath}, @tanqory/theme-abi ${r.abiVersion})` : [`manifest INVALID: ${r.errors.length} problem(s)`, ...r.errors.map((e) => `- ${e.path}: ${e.message}${e.file ? ` (${e.file}:${e.line})` : ''}${e.hint ? `\n fix: ${e.hint}` : ''}`)].join('\n')
@@ -345,6 +362,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
345
362
  },
346
363
  guard(async ({ tier }) => {
347
364
  needTheme()
365
+ refuseInSandbox()
348
366
  const env = await runCli(['theme', 'check', ...(tier ? ['--tier', tier] : [])], cliOpts)
349
367
  return checkResult(env, tier)
350
368
  }),
@@ -362,6 +380,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
362
380
  },
363
381
  guard(async () => {
364
382
  needTheme()
383
+ refuseInSandbox()
365
384
  return checkResult(await runCli(['theme', 'check', '--tier', 'storefront'], cliOpts), 'storefront')
366
385
  }),
367
386
  )
@@ -372,7 +391,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
372
391
  {
373
392
  title: 'Is `tanqory theme dev` running?',
374
393
  description:
375
- "Read .tanqory/dev-server.json and ask the running dev server for /__tq/status: url, store, mode (store = real dev-store data, mock = fixture data), the theme it serves and its last build with errors (file:line). Never starts or stops the dev server; when it is not running the answer says how to start it (in the background: `npm run dev -- --mock`).",
394
+ "Read .tanqory/dev-server.json and ask the running dev server for /__tq/status: url, store, mode (store = real dev-store data, mock = fixture data), the theme it serves and its last build with errors (file:line). Never starts or stops the dev server (start_dev_server / stop_dev_server do); when it is not running the answer says how to start it.",
376
395
  inputSchema: {},
377
396
  outputSchema: {
378
397
  ...base,
@@ -401,6 +420,59 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
401
420
  }),
402
421
  )
403
422
 
423
+ const devOut = {
424
+ ...base,
425
+ running: z.boolean().optional(),
426
+ alreadyRunning: z.boolean().optional(),
427
+ url: z.string().optional(),
428
+ port: z.number().optional(),
429
+ mode: z.string().optional(),
430
+ store: z.any().optional(),
431
+ pid: z.number().optional(),
432
+ editor: z.string().optional(),
433
+ lastBuild: z.any().optional(),
434
+ log: z.string().optional(),
435
+ }
436
+ server.registerTool(
437
+ 'start_dev_server',
438
+ {
439
+ title: 'Start `tanqory theme dev`',
440
+ description:
441
+ "Start the theme's dev server (`tanqory theme dev`) on http://localhost:9292 and wait until it serves: workerd under the platform's production shim, rebuilt on every save, the editor at /__editor. mock: true uses the offline fixture store; without it, the store this theme is linked to (needs `npx tanqory login` in a terminal first). Returns the url, pid and last build. Already running: returns that one. Use this instead of starting it from a shell: this server runs it outside any shell sandbox, so screenshot, get_page and stop_dev_server can always reach it. It is stopped when this MCP server shuts down.",
442
+ inputSchema: {
443
+ mock: z.boolean().optional().describe('true = the offline fixture store (no login needed); default false = the linked store'),
444
+ port: z.number().int().min(1024).max(65535).optional().describe('Port on localhost (default 9292)'),
445
+ },
446
+ outputSchema: devOut,
447
+ // openWorldHint false: it starts a local process. (Codex asks for approval on any non-read-only tool with an
448
+ // open-world or destructive hint, and `codex exec` never approves: P3e run C1 lost every screenshot to that.)
449
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
450
+ },
451
+ guard(async ({ mock, port }) => {
452
+ needTheme()
453
+ const r = await devControl.start({ mock: !!mock, port })
454
+ const text = [`Dev server ${r.alreadyRunning ? 'already running' : 'started'}: ${r.url} (${r.mode}${r.store?.handle ? `, store ${r.store.handle}` : ''}, pid ${r.pid}); editor ${r.editor}`, `last build: ${r.lastBuild?.ok === false ? 'FAILED' : 'ok'}`, ...(r.lastBuild?.errors ?? []).map((e) => `- ${e.file ?? '?'}${e.line ? `:${e.line}` : ''}: ${e.message}`), 'Next: screenshot / get_page the page you change. Stop it with stop_dev_server when you are finished.'].join('\n')
455
+ return done(r, text)
456
+ }),
457
+ )
458
+ server.registerTool(
459
+ 'stop_dev_server',
460
+ {
461
+ title: 'Stop `tanqory theme dev`',
462
+ description:
463
+ "Stop this theme's dev server, and only it: `tanqory theme dev --stop` (an authenticated POST /__tq/stop with the token in .tanqory/dev-server.json, else a signal to its verified pid). Works whoever started it, including a sandboxed shell. Never stops a process by name. Not running: stopped is false with the reason.",
464
+ inputSchema: {},
465
+ outputSchema: { ...base, stopped: z.boolean().optional(), reason: z.string().optional(), pid: z.number().optional(), url: z.string().optional(), via: z.string().optional() },
466
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
467
+ },
468
+ guard(async () => {
469
+ needTheme()
470
+ const r = await devControl.stop()
471
+ const text = r.stopped ? `Stopped the dev server (pid ${r.pid}, ${r.url}${r.via ? `, via ${r.via}` : ''}).` : `No dev server was running (${r.reason ?? 'not_running'}).`
472
+ return done({ stopped: !!r.stopped, ...(r.reason ? { reason: r.reason } : {}), ...(r.pid ? { pid: r.pid } : {}), ...(r.url ? { url: r.url } : {}), ...(r.via ? { via: r.via } : {}) }, text)
473
+ }),
474
+ )
475
+
404
476
  const pageArgs = {
405
477
  path: z.string().regex(/^\//, 'must start with /').max(500).describe('Storefront path, e.g. "/", "/products/linen-shirt", "/collections/all?sort=price-asc"'),
406
478
  locale: z.string().max(10).optional().describe('Language, e.g. "th" or "en"'),
@@ -442,6 +514,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
442
514
  const consoleErrors = [...(page.consoleErrors ?? []).map((m) => (typeof m === 'string' ? { kind: 'server-reported', message: m } : m))]
443
515
  let browserInfo = { available: true }
444
516
  try {
517
+ refuseInSandbox()
445
518
  const r = await browser.withPage(browserUrl(rec.url, { path, locale, market }), { viewport: viewport ?? 'desktop' }, async () => null)
446
519
  consoleErrors.push(...r.errors.map((e) => ({ ...e, kind: `browser-${e.kind}` })))
447
520
  } catch (e) {
@@ -465,6 +538,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
465
538
  annotations: READ,
466
539
  },
467
540
  guard(async ({ path, locale, market, viewport, fullPage }) => {
541
+ refuseInSandbox()
468
542
  const rec = requireDevServer(theme.root)
469
543
  const url = browserUrl(rec.url, { path, locale, market })
470
544
  const r = await browser.withPage(url, { viewport }, async (page) => {
@@ -491,7 +565,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
491
565
  {
492
566
  title: 'List your development stores',
493
567
  description:
494
- "The development stores your Tanqory login can use (`tanqory stores --json`), and which one this theme is linked to (tanqory.config.json). Read-only; it never creates a store and never takes a store id. When you are not logged in the answer says to run `tanqory login` in a terminal.",
568
+ "The development stores of your Tanqory developer login, plus any store you signed in to (`tanqory stores --json`; each row has `type` 'development' or 'store'), and which one this theme is linked to (tanqory.config.json). Read-only; it never creates a store and never takes a store id. When you are not logged in the answer says to run `tanqory login` in a terminal; a developer with no development store yet can ask the user to run `tanqory stores create --dev --name \"<name>\"` (free, no card).",
495
569
  inputSchema: {},
496
570
  outputSchema: { ...base, stores: z.array(z.any()).optional(), linked: z.any().optional(), raw: z.any().optional() },
497
571
  annotations: { ...READ, openWorldHint: true },
@@ -589,6 +663,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
589
663
  const close = server.close.bind(server)
590
664
  server.close = async () => {
591
665
  await browser.close()
666
+ await devControl.close()
592
667
  await close()
593
668
  }
594
669
  return { server, theme, browser }