@tanqory/dev-mcp 0.1.0 → 0.1.3
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 +30 -11
- package/bin/tanqory-dev-mcp.mjs +2 -2
- 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 +153 -30
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,12 +68,16 @@ 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. |
|
|
60
77
|
| `get_store_data_summary` | Calls `GET /__tq/store-summary` on the dev server: the products, collections, markets, languages and content you can design against. |
|
|
61
|
-
| `push_draft
|
|
78
|
+
| `push_draft {confirm?, planDigest?}` | Two steps. Without `confirm`: runs `tanqory theme push --build-only --dry-run` and returns the plan (files, bytes, sha256, target development store, `planDigest`); nothing is uploaded. With `confirm: true` and that `planDigest`: runs `tanqory theme push --build-only --plan-digest <d>`: the platform builds the theme in its sandboxed lane, runs conformance and signs it, **private to your development store**. `plan_changed` if the theme changed in between. Nothing is installed or published. |
|
|
79
|
+
| `get_build_status {buildId?}` | Runs `tanqory theme builds [--id <buildId>] --json`: one build (status, verdict, conformance summary, signature, error) or the store's recent builds and the daily quota. |
|
|
80
|
+
| `get_preview_url` | **Refuses today** with `preview_unavailable`: a pushed build is not installed as a draft yet, so no preview URL exists. It never invents one. |
|
|
62
81
|
|
|
63
82
|
**Prompts:** `new-theme`, `new-section`, `fix-check-report` and `port-from-another-template-language`.
|
|
64
83
|
|
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')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanqory/dev-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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.6"
|
|
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; push_draft builds and signs privately for your development store and installs nothing.`
|
|
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 },
|
|
@@ -523,49 +597,97 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
|
|
|
523
597
|
}),
|
|
524
598
|
)
|
|
525
599
|
|
|
526
|
-
// ──
|
|
527
|
-
const
|
|
528
|
-
code: '
|
|
600
|
+
// ── platform builds (P5): push_draft builds + signs privately; install/preview arrive with P6 ──
|
|
601
|
+
const PREVIEW_REFUSAL = {
|
|
602
|
+
code: 'preview_unavailable',
|
|
529
603
|
message:
|
|
530
|
-
|
|
531
|
-
hint: '
|
|
532
|
-
|
|
604
|
+
'Installing a pushed theme as an unpublished draft, and its preview URL, are not available yet: push_draft builds, checks and signs the theme privately for your development store, and installs nothing. No preview URL exists, so none is returned.',
|
|
605
|
+
hint: 'Use localhost (dev_server_status / screenshot) to look at the theme. push_draft + get_build_status tell you whether the platform build passed.',
|
|
606
|
+
}
|
|
607
|
+
const buildOut = { ...base, mode: z.string().optional(), planDigest: z.string().optional(), buildId: z.string().optional(), verdict: z.string().optional(), build: z.any().optional(), store: z.any().optional(), theme: z.any().optional(), upload: z.any().optional(), notUploaded: z.any().optional(), builds: z.array(z.any()).optional(), quota: z.any().optional() }
|
|
608
|
+
const buildLines = (b) => {
|
|
609
|
+
const c = b?.conformance ?? {}
|
|
610
|
+
return [
|
|
611
|
+
`build ${b?.id}: ${b?.status} (verdict ${b?.verdict})${b?.signed ? ', signed' : ''}, private to store ${b?.storeId}`,
|
|
612
|
+
...(c.status ? [`conformance: ${c.status}${c.basis ? ` on the ${c.basis}` : ''}`] : []),
|
|
613
|
+
...(c.warnings ?? []).map((w) => `budget warning: ${w.name} ${w.value} ${w.unit} (budget ${w.budget})`),
|
|
614
|
+
...(b?.error ? [`${b.status}: [${b.error.kind}${b.error.code ? `/${b.error.code}` : ''}] ${b.error.message}${b.error.path ? ` (${b.error.path})` : ''}`] : []),
|
|
615
|
+
]
|
|
533
616
|
}
|
|
534
|
-
const refusalOut = { ...base, refused: z.literal(true).optional() }
|
|
535
|
-
const refuse = () => ({ isError: true, content: [{ type: 'text', text: `${REFUSAL.code}: ${REFUSAL.message}\nWhat to do instead: ${REFUSAL.instead.join('; ')}. ${REFUSAL.hint}` }], structuredContent: { ok: false, refused: true, error: REFUSAL } })
|
|
536
617
|
|
|
537
618
|
server.registerTool(
|
|
538
619
|
'push_draft',
|
|
539
620
|
{
|
|
540
|
-
title: '
|
|
541
|
-
description:
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
621
|
+
title: 'Build the theme on the platform (private, signed; installs nothing)',
|
|
622
|
+
description:
|
|
623
|
+
'Uploads the theme SOURCE (git-tracked files, .gitignore respected; node_modules, dist, reports, .tanqory/dev and credential files are never uploaded) to the platform\'s sandboxed build lane for your linked DEVELOPMENT store, and waits for build → conformance → signing (`tanqory theme push --build-only`). Two steps: call WITHOUT confirm to get the plan (files, bytes, sha256, target store, planDigest; nothing is uploaded). Then call again with confirm: true and that planDigest; if the theme changed in between it refuses with plan_changed. The result is a signed build PRIVATE to that development store: nothing is installed, previewed or published, and there is no preview URL yet. Run check_theme first: a theme that fails it fails the platform build too. Needs a developer login (`tanqory login`) and a linked development store.',
|
|
624
|
+
inputSchema: {
|
|
625
|
+
confirm: z.boolean().optional().describe('true = upload the plan identified by planDigest'),
|
|
626
|
+
planDigest: z.string().max(100).optional().describe('From the plan step; required with confirm: true'),
|
|
627
|
+
},
|
|
628
|
+
outputSchema: buildOut,
|
|
629
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
545
630
|
},
|
|
546
|
-
guard(async () =>
|
|
631
|
+
guard(async ({ confirm, planDigest }) => {
|
|
632
|
+
const root = needTheme()
|
|
633
|
+
if (!confirm) {
|
|
634
|
+
const env = await runCli(['theme', 'push', '--build-only', '--dry-run'], { ...cliOpts, root })
|
|
635
|
+
if (!env.ok) return fail(env.error?.code ?? 'push_plan_failed', env.error?.message ?? '`tanqory theme push --dry-run` failed', env.error?.hint)
|
|
636
|
+
const d = env.data ?? {}
|
|
637
|
+
const text = [
|
|
638
|
+
`PLAN (nothing uploaded): ${d.upload?.files} files, ${d.upload?.bytes} bytes, sha256 ${String(d.upload?.sha256 ?? '').slice(0, 12)}…, theme "${d.theme?.handle}" → development store ${d.store?.handle ?? d.store?.id}.`,
|
|
639
|
+
...(d.notUploaded?.secrets?.length ? [`Never uploaded (look like credentials): ${d.notUploaded.secrets.join(', ')}`] : []),
|
|
640
|
+
`To build it, call push_draft again with confirm: true, planDigest: "${d.planDigest}". The build is private to that development store; nothing is installed.`,
|
|
641
|
+
].join('\n')
|
|
642
|
+
return done({ mode: 'plan', planDigest: d.planDigest, store: d.store, theme: d.theme, upload: d.upload, notUploaded: d.notUploaded }, text)
|
|
643
|
+
}
|
|
644
|
+
if (!planDigest) return fail('plan_digest_required', 'confirm: true needs the planDigest of a plan you reviewed.', 'Call push_draft without confirm first.')
|
|
645
|
+
const env = await runCli(['theme', 'push', '--build-only', '--plan-digest', planDigest], { ...cliOpts, root, timeoutMs: 25 * 60 * 1000 })
|
|
646
|
+
const d = env.data ?? {}
|
|
647
|
+
if (!env.ok) {
|
|
648
|
+
const lines = d.build ? buildLines(d.build) : []
|
|
649
|
+
return {
|
|
650
|
+
isError: true,
|
|
651
|
+
content: [{ type: 'text', text: [`${env.error?.code}: ${env.error?.message}`, ...lines, env.error?.hint ? `What to do: ${env.error.hint}` : ''].filter(Boolean).join('\n') }],
|
|
652
|
+
structuredContent: { ok: false, error: env.error ?? { code: 'push_failed', message: 'push failed' }, ...(d.build ? { mode: 'build', buildId: d.build.id, verdict: d.build.verdict, build: d.build, store: d.store, theme: d.theme } : {}), ...(d.planDigest ? { planDigest: d.planDigest } : {}) },
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return done(
|
|
656
|
+
{ mode: 'build', planDigest: d.planDigest, buildId: d.buildId, verdict: d.verdict, build: d.build, store: d.store, theme: d.theme, upload: d.upload, quota: d.quota },
|
|
657
|
+
[...buildLines(d.build), 'Nothing was installed or published; there is no preview URL yet. Follow up with get_build_status if it is still pending.'].join('\n'),
|
|
658
|
+
)
|
|
659
|
+
}),
|
|
547
660
|
)
|
|
548
661
|
server.registerTool(
|
|
549
662
|
'get_build_status',
|
|
550
663
|
{
|
|
551
|
-
title: 'Status of
|
|
552
|
-
description:
|
|
553
|
-
|
|
554
|
-
|
|
664
|
+
title: 'Status of your platform theme builds',
|
|
665
|
+
description:
|
|
666
|
+
'With buildId: that build of your linked development store (status, verdict, conformance summary, signature, error with kind/code/path). Without: the store\'s recent builds and the daily build quota (`tanqory theme builds --json`). Read-only.',
|
|
667
|
+
inputSchema: { buildId: z.string().uuid().optional().describe('From push_draft; omit for the recent builds') },
|
|
668
|
+
outputSchema: buildOut,
|
|
555
669
|
annotations: { ...READ, openWorldHint: true },
|
|
556
670
|
},
|
|
557
|
-
guard(async () =>
|
|
671
|
+
guard(async ({ buildId }) => {
|
|
672
|
+
const root = needTheme()
|
|
673
|
+
const env = await runCli(['theme', 'builds', ...(buildId ? ['--id', buildId] : [])], { ...cliOpts, root })
|
|
674
|
+
if (!env.ok) return fail(env.error?.code ?? 'builds_failed', env.error?.message ?? '`tanqory theme builds` failed', env.error?.hint)
|
|
675
|
+
const d = env.data ?? {}
|
|
676
|
+
if (buildId) return done({ buildId, verdict: d.build?.verdict, build: d.build, store: d.store }, buildLines(d.build).join('\n'))
|
|
677
|
+
const builds = d.builds ?? []
|
|
678
|
+
return done({ builds, store: d.store, quota: d.quota }, [`${builds.length} recent build(s) for ${d.store?.handle ?? d.store?.id}; ${d.quota?.used ?? '?'} of ${d.quota?.limit ?? '?'} used in 24 h`, ...builds.map((b) => `- ${b.id} ${b.status} (${b.verdict})${b.theme?.handle ? ` theme ${b.theme.handle}` : ''} ${b.createdAt}`)].join('\n'))
|
|
679
|
+
}),
|
|
558
680
|
)
|
|
559
681
|
server.registerTool(
|
|
560
682
|
'get_preview_url',
|
|
561
683
|
{
|
|
562
684
|
title: 'Preview URL of a draft (not available yet)',
|
|
563
|
-
description: `Would return the shareable preview and editor URLs of
|
|
685
|
+
description: `Would return the shareable preview and editor URLs of an installed draft. ALWAYS REFUSES TODAY with ${PREVIEW_REFUSAL.code}: push_draft builds and signs privately but installs nothing, so no preview URL exists. Never invent one; localhost (dev_server_status) is the only preview today.`,
|
|
564
686
|
inputSchema: {},
|
|
565
|
-
outputSchema:
|
|
687
|
+
outputSchema: { ...base, refused: z.literal(true).optional() },
|
|
566
688
|
annotations: { ...READ, openWorldHint: true },
|
|
567
689
|
},
|
|
568
|
-
guard(async () =>
|
|
690
|
+
guard(async () => ({ isError: true, content: [{ type: 'text', text: `${PREVIEW_REFUSAL.code}: ${PREVIEW_REFUSAL.message}\nWhat to do instead: ${PREVIEW_REFUSAL.hint}` }], structuredContent: { ok: false, refused: true, error: PREVIEW_REFUSAL } })),
|
|
569
691
|
)
|
|
570
692
|
|
|
571
693
|
// ── resources: every document as tanqory://docs/<id> ──────────────────────
|
|
@@ -589,6 +711,7 @@ Two steps: call WITHOUT confirm to get the plan (every file, full new contents,
|
|
|
589
711
|
const close = server.close.bind(server)
|
|
590
712
|
server.close = async () => {
|
|
591
713
|
await browser.close()
|
|
714
|
+
await devControl.close()
|
|
592
715
|
await close()
|
|
593
716
|
}
|
|
594
717
|
return { server, theme, browser }
|