@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 +27 -10
- package/bin/tanqory-dev-mcp.mjs +2 -2
- package/bundled/theme-conformance/docs/storefront-tier.md +32 -3
- package/bundled/versions.json +1 -1
- package/package.json +2 -2
- package/src/browser.mjs +4 -2
- package/src/devcontrol.mjs +118 -0
- package/src/devserver.mjs +1 -1
- package/src/sandbox.mjs +13 -0
- package/src/server.mjs +83 -8
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
|
|
21
|
-
theme
|
|
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
|
-
{
|
|
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 = "
|
|
32
|
-
args = ["
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
| `
|
|
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. |
|
package/bin/tanqory-dev-mcp.mjs
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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× (
|
|
109
|
-
150 ms + (bytes on the wire × 8 / 1638.4 kbps) by the gateway, text counted
|
|
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
|
package/bundled/versions.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanqory/dev-mcp",
|
|
3
|
-
"version": "0.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.
|
|
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 =
|
|
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
|
|
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) {
|
package/src/sandbox.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
|
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\`
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
|
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 }
|