kizlo 0.20.2 → 0.21.0
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/dist/cli/commands/_setup.js +5 -1
- package/dist/cli/commands/dev.js +63 -20
- package/dist/cli/daemon/config.js +27 -3
- package/dist/cli/daemon/generate.js +8 -2
- package/dist/cli/daemon/watch.js +11 -3
- package/dist/cli/mcp/index.js +46 -0
- package/dist/cli/mcp/routes.js +42 -0
- package/dist/cli/mcp/schema.js +104 -0
- package/dist/cli/mcp/server.js +157 -0
- package/dist/cli/mcp/state.js +7 -0
- package/dist/cli/mcp/tools.js +103 -0
- package/dist/config.d.ts +17 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/integrations/astro/integration.js +2 -0
- package/dist/integrations/nextjs/integration.js +2 -0
- package/dist/integrations/node/integration.js +2 -0
- package/dist/integrations/tanstack-start/integration.js +2 -0
- package/dist/kizlo.js +4 -3
- package/dist/package.js +1 -1
- package/dist/post/schema.d.ts +4 -4
- package/dist/procedures.d.ts +10 -10
- package/dist/seo/schema.d.ts +5 -5
- package/dist/shared/constants.js +3 -1
- package/dist/shared/integration.d.ts +9 -1
- package/dist/shared/integration.js +6 -3
- package/dist/wordpress/generate.js +6 -1
- package/package.json +2 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_WORDPRESS_TAG, LOCAL_DIR_REL } from "../wp/constants.js";
|
|
2
2
|
import { composeStop, createStack, dockerHint, dockerStatus } from "../wp/docker.js";
|
|
3
3
|
import { DEFAULT_ENV_KEYS, availablePackageManagers, detectInvokingPackageManager, ensureGitignored, envGroups, envKeysPresent, mergeEnv, pickStackPort, writeFileIfAbsent } from "../utils.js";
|
|
4
|
-
import { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveStackName, stackProject } from "../daemon/config.js";
|
|
4
|
+
import { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, DEFAULT_MCP_PORT, resolveStackName, stackProject } from "../daemon/config.js";
|
|
5
5
|
import { promptFragment, resolvePromptDefault } from "../presets/template.js";
|
|
6
6
|
import { createAdminAppPassword } from "../wp/bootstrap.js";
|
|
7
7
|
import { bootstrapDev } from "../wp/dev.js";
|
|
@@ -324,6 +324,10 @@ function devConfigFor(cwd) {
|
|
|
324
324
|
return {
|
|
325
325
|
configDir: cwd,
|
|
326
326
|
project: stackProject(resolveStackName(cwd), "dev"),
|
|
327
|
+
mcp: {
|
|
328
|
+
port: DEFAULT_MCP_PORT,
|
|
329
|
+
portExplicit: false
|
|
330
|
+
},
|
|
327
331
|
port: DEFAULT_DEV_PORT,
|
|
328
332
|
portExplicit: false,
|
|
329
333
|
dbPort: DEFAULT_DEV_DB_PORT,
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createStack, dockerHint, dockerStatus, stackStatus } from "../wp/docker
|
|
|
2
2
|
import { findConfigDir } from "../wp/utils.js";
|
|
3
3
|
import { log } from "../daemon/logger.js";
|
|
4
4
|
import { DEFAULT_ENV_KEYS, ensureGitignored, envGroups, groupDefault, mergeEnv, pickStackPort, withSpinner } from "../utils.js";
|
|
5
|
-
import { resolveDevConfig, usesLocalWordPress } from "../daemon/config.js";
|
|
5
|
+
import { resolveDevConfig, resolveMcpConfig, usesLocalWordPress } from "../daemon/config.js";
|
|
6
6
|
import { bootstrapDev } from "../wp/dev.js";
|
|
7
7
|
import { reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, unregisterSession } from "../wp/session.js";
|
|
8
8
|
import { syncSiteSettings } from "../wp/settings.js";
|
|
@@ -10,6 +10,8 @@ import { devStack } from "../wp/stack.js";
|
|
|
10
10
|
import { palette } from "../banner.js";
|
|
11
11
|
import { watchReload } from "../daemon/reload.js";
|
|
12
12
|
import { startWatcher } from "../daemon/watch.js";
|
|
13
|
+
import { createMcpState } from "../mcp/state.js";
|
|
14
|
+
import { resolveMcpPort, startMcp } from "../mcp/index.js";
|
|
13
15
|
import { join } from "node:path";
|
|
14
16
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
17
|
import { defineCommand } from "citty";
|
|
@@ -42,14 +44,16 @@ function note(text) {
|
|
|
42
44
|
* Print the connection summary as an aligned, Next.js-style block. Shows the wp-admin URL on
|
|
43
45
|
* the loopback ("Local") and — when local WordPress is installed on a LAN address rather than
|
|
44
46
|
* localhost — on the network, so it can be opened from other devices. On a fresh install, also
|
|
45
|
-
* show the default wp-admin login.
|
|
47
|
+
* show the default wp-admin login. The MCP row is the address to paste into a harness's own
|
|
48
|
+
* configuration: nothing writes one, because each harness reads a different file.
|
|
46
49
|
*/
|
|
47
|
-
function printSummary(info) {
|
|
50
|
+
function printSummary(info, mcpUrl) {
|
|
48
51
|
const { cyan, dim, reset } = palette();
|
|
49
52
|
const { port, hostname } = new URL(info.url);
|
|
50
53
|
const rows = [["WP Local", `http://localhost:${port}/wp-admin`]];
|
|
51
54
|
if (hostname !== "localhost") rows.push(["WP Network", `${info.url}/wp-admin`]);
|
|
52
55
|
rows.push(["Database Connection", `127.0.0.1:${info.dbPort}`]);
|
|
56
|
+
if (mcpUrl) rows.push(["MCP", mcpUrl]);
|
|
53
57
|
const width = Math.max(...rows.map(([label]) => label.length)) + 1;
|
|
54
58
|
const lines = ["", ...rows.map(([label, value]) => ` ${dim}- ${`${label}:`.padEnd(width)}${reset} ${cyan}${value}${reset}`)];
|
|
55
59
|
if (info.adminPassword) lines.push("", ` ${dim}admin login:${reset} ${info.username} / ${info.adminPassword}`);
|
|
@@ -180,7 +184,7 @@ function stopping(reason) {
|
|
|
180
184
|
* and the teardown work against. Shared by the first boot and every reload reboot, so a changed
|
|
181
185
|
* `dev.port` in `kizlo.config.ts` is re-resolved the same way each time.
|
|
182
186
|
*/
|
|
183
|
-
async function prepareStack(cfg) {
|
|
187
|
+
async function prepareStack(cfg, mcpPort) {
|
|
184
188
|
await removeProjectContainers(cfg.project);
|
|
185
189
|
const port = await pickStackPort(cfg.port, {
|
|
186
190
|
fixed: cfg.portExplicit,
|
|
@@ -194,7 +198,8 @@ async function prepareStack(cfg) {
|
|
|
194
198
|
const ready = {
|
|
195
199
|
...cfg,
|
|
196
200
|
port,
|
|
197
|
-
dbPort
|
|
201
|
+
dbPort,
|
|
202
|
+
mcpPort: mcpPort ?? await resolveMcpPort(cfg.mcp)
|
|
198
203
|
};
|
|
199
204
|
createStack(devStack(ready));
|
|
200
205
|
if (port !== cfg.port) note(`Port ${cfg.port} is in use — serving on ${port} instead.`);
|
|
@@ -207,7 +212,7 @@ async function prepareStack(cfg) {
|
|
|
207
212
|
* one-time Ctrl+C hint. `start` is when the boot began, so the timing spans the whole reboot including
|
|
208
213
|
* the spinner. The `.env` writes here are why the caller pauses the reload watcher around a reboot.
|
|
209
214
|
*/
|
|
210
|
-
async function
|
|
215
|
+
async function syncEnv(cfg, creds) {
|
|
211
216
|
if (creds.appPassword) {
|
|
212
217
|
const env = updateWpEnv(cfg, creds);
|
|
213
218
|
note("Updated .env with the new WordPress credentials");
|
|
@@ -224,7 +229,16 @@ async function report(cfg, creds, reloaded, start) {
|
|
|
224
229
|
if (!sync.ok) note(`Could not sync the site settings to WordPress (${sync.error}) — make sure the kizlo plugin is active, then set them from the Kizlo settings.`);
|
|
225
230
|
}
|
|
226
231
|
} else if (syncLocalUrl(cfg.configDir, creds.url)) note("Updated .env WordPress URL to the current network address — restart your app to pick it up");
|
|
227
|
-
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Print the summary and the timed ready line, once everything it names is actually up. Split from
|
|
235
|
+
* {@link syncEnv} because the two have opposite ordering needs: the `.env` writes have to land before
|
|
236
|
+
* the watcher starts, since that is where it reads the connection from, while the summary has to wait
|
|
237
|
+
* until after, so the MCP address it prints belongs to a server that exists. `mcpUrl` is absent when
|
|
238
|
+
* MCP is not running, and the row is then left out rather than pointing at nothing.
|
|
239
|
+
*/
|
|
240
|
+
function printReady(creds, reloaded, start, mcpUrl) {
|
|
241
|
+
printSummary(creds, mcpUrl);
|
|
228
242
|
const { green, dim, bold, reset } = palette();
|
|
229
243
|
const ms = Date.now() - start;
|
|
230
244
|
const took = ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
@@ -236,9 +250,14 @@ async function report(cfg, creds, reloaded, start) {
|
|
|
236
250
|
* then report. The first-boot path only — reboots go through {@link rebootStack}, which also folds the
|
|
237
251
|
* prepare step under the spinner.
|
|
238
252
|
*/
|
|
239
|
-
async function
|
|
253
|
+
async function bootAndSync(cfg) {
|
|
240
254
|
const start = Date.now();
|
|
241
|
-
|
|
255
|
+
const creds = await withSpinner("Starting local WordPress", () => bootstrapDev(cfg));
|
|
256
|
+
await syncEnv(cfg, creds);
|
|
257
|
+
return {
|
|
258
|
+
creds,
|
|
259
|
+
start
|
|
260
|
+
};
|
|
242
261
|
}
|
|
243
262
|
/**
|
|
244
263
|
* Reboot the whole stack in place after a config/env change: re-resolve the config, recreate the
|
|
@@ -247,17 +266,21 @@ async function bootAndReport(cfg) {
|
|
|
247
266
|
* the first boot, so this never re-arms. Returns the freshly resolved config (ports may have moved) so
|
|
248
267
|
* the caller can re-target its watchers.
|
|
249
268
|
*/
|
|
250
|
-
async function rebootStack(cwd, label) {
|
|
269
|
+
async function rebootStack(cwd, label, mcpPort) {
|
|
251
270
|
const start = Date.now();
|
|
252
271
|
const { ready, creds } = await withSpinner(label, async () => {
|
|
253
|
-
const ready$1 = await prepareStack(await resolveDevConfig(cwd));
|
|
272
|
+
const ready$1 = await prepareStack(await resolveDevConfig(cwd), mcpPort);
|
|
254
273
|
return {
|
|
255
274
|
ready: ready$1,
|
|
256
275
|
creds: await bootstrapDev(ready$1)
|
|
257
276
|
};
|
|
258
277
|
});
|
|
259
|
-
await
|
|
260
|
-
return
|
|
278
|
+
await syncEnv(ready, creds);
|
|
279
|
+
return {
|
|
280
|
+
ready,
|
|
281
|
+
creds,
|
|
282
|
+
start
|
|
283
|
+
};
|
|
261
284
|
}
|
|
262
285
|
/**
|
|
263
286
|
* Boot local WordPress and run in the foreground until exit — the path behind bare `kizlo dev`. Arms
|
|
@@ -273,20 +296,33 @@ async function startForeground(cfg) {
|
|
|
273
296
|
const cwd = process.cwd();
|
|
274
297
|
let ready = await prepareStack(cfg);
|
|
275
298
|
const shutdown = await armForegroundTeardown(ready);
|
|
276
|
-
await
|
|
299
|
+
const { creds, start } = await bootAndSync(ready);
|
|
277
300
|
const stack = {
|
|
278
301
|
status: () => stackStatus(ready.project),
|
|
279
302
|
onStopped: () => shutdown()
|
|
280
303
|
};
|
|
281
|
-
|
|
304
|
+
const mcp = createMcpState();
|
|
305
|
+
const mcpPort = ready.mcpPort;
|
|
306
|
+
let stopContract = await startWatcher(ready.configDir, {
|
|
307
|
+
stack,
|
|
308
|
+
mcp
|
|
309
|
+
});
|
|
310
|
+
const mcpServer = stopContract && mcpPort !== void 0 ? await startMcp(mcp, mcpPort) : void 0;
|
|
311
|
+
if (!stopContract) log.info("Another `kizlo dev` is serving MCP for this project.");
|
|
312
|
+
printReady(creds, false, start, mcpServer?.url);
|
|
282
313
|
let reloading = false;
|
|
283
314
|
const reload = async (changed) => {
|
|
284
315
|
if (reloading) return;
|
|
285
316
|
reloading = true;
|
|
286
317
|
try {
|
|
287
318
|
stopContract?.();
|
|
288
|
-
|
|
289
|
-
|
|
319
|
+
const rebooted = await rebootStack(cwd, `${changed} changed — restarting the session`, mcpPort);
|
|
320
|
+
ready = rebooted.ready;
|
|
321
|
+
stopContract = await startWatcher(ready.configDir, {
|
|
322
|
+
stack,
|
|
323
|
+
mcp
|
|
324
|
+
});
|
|
325
|
+
printReady(rebooted.creds, true, rebooted.start, mcpServer?.url);
|
|
290
326
|
} catch (error) {
|
|
291
327
|
log.error("Failed to reload the dev session:", error);
|
|
292
328
|
} finally {
|
|
@@ -299,6 +335,7 @@ async function startForeground(cfg) {
|
|
|
299
335
|
process.on("exit", () => {
|
|
300
336
|
stopContract?.();
|
|
301
337
|
stopReload();
|
|
338
|
+
mcpServer?.stop();
|
|
302
339
|
});
|
|
303
340
|
setInterval(() => {}, 1 << 30);
|
|
304
341
|
await new Promise(() => {});
|
|
@@ -312,15 +349,20 @@ async function startForeground(cfg) {
|
|
|
312
349
|
* persistent watcher keeps the process alive on its own.
|
|
313
350
|
*/
|
|
314
351
|
async function watchOnly(cwd) {
|
|
315
|
-
|
|
316
|
-
|
|
352
|
+
const mcp = createMcpState();
|
|
353
|
+
let stop = await startWatcher(cwd, { mcp });
|
|
354
|
+
if (!stop) {
|
|
355
|
+
log.info("Another `kizlo dev` is serving MCP for this project.");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const mcpServer = await startMcp(mcp, await resolveMcpPort(await resolveMcpConfig(cwd)));
|
|
317
359
|
let reloading = false;
|
|
318
360
|
const reload = async () => {
|
|
319
361
|
if (reloading) return;
|
|
320
362
|
reloading = true;
|
|
321
363
|
try {
|
|
322
364
|
stop?.();
|
|
323
|
-
stop = await startWatcher(cwd);
|
|
365
|
+
stop = await startWatcher(cwd, { mcp });
|
|
324
366
|
} finally {
|
|
325
367
|
setTimeout(() => {
|
|
326
368
|
reloading = false;
|
|
@@ -331,6 +373,7 @@ async function watchOnly(cwd) {
|
|
|
331
373
|
const shutdown = () => {
|
|
332
374
|
stop?.();
|
|
333
375
|
stopReload();
|
|
376
|
+
mcpServer?.stop();
|
|
334
377
|
};
|
|
335
378
|
process.on("exit", shutdown);
|
|
336
379
|
for (const signal of [
|
|
@@ -50,6 +50,7 @@ const testStackSchema = z.object({
|
|
|
50
50
|
]).optional(),
|
|
51
51
|
command: z.string().optional()
|
|
52
52
|
});
|
|
53
|
+
const mcpSchema = z.object({ port: z.number().int().positive().optional() });
|
|
53
54
|
const localSchema = z.union([z.boolean(), z.object({
|
|
54
55
|
enable: z.boolean().optional(),
|
|
55
56
|
name: z.string().optional(),
|
|
@@ -62,6 +63,7 @@ const configSchema = z.object({
|
|
|
62
63
|
dir: dirSchema.optional(),
|
|
63
64
|
alias: z.string().optional(),
|
|
64
65
|
local: localSchema.optional(),
|
|
66
|
+
mcp: mcpSchema.optional(),
|
|
65
67
|
wordpressClientDir: removedKey("`wordpressClientDir` has been removed. Use `dir: { introspection }` instead."),
|
|
66
68
|
name: removedKey("`name` is no longer a config root key. Move it under `local.name`."),
|
|
67
69
|
worktrees: removedKey("`worktrees` is no longer a config root key. Move it under `local.worktrees`."),
|
|
@@ -106,6 +108,16 @@ const CONFIG_FILES = [
|
|
|
106
108
|
];
|
|
107
109
|
const DEFAULT_DEV_PORT = 8080;
|
|
108
110
|
const DEFAULT_DEV_DB_PORT = 3307;
|
|
111
|
+
/**
|
|
112
|
+
* Port the MCP server binds on `127.0.0.1` when `mcp.port` is unset. It steps off a collision like the
|
|
113
|
+
* stack ports do, so two projects need no configuration to run side by side. A harness config names a
|
|
114
|
+
* port and cannot follow one that moves, which is what setting `mcp.port` is for.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately clear of the stack ports' own scan ranges. Each default steps up to `PORT_SCAN_RANGE`
|
|
117
|
+
* ports looking for a free one, so a default inside 8080-8179 could be handed to the dev stack and
|
|
118
|
+
* then claimed again here, since the MCP probe runs before compose has published anything.
|
|
119
|
+
*/
|
|
120
|
+
const DEFAULT_MCP_PORT = 8300;
|
|
109
121
|
const DEFAULT_TEST_PORT = 8889;
|
|
110
122
|
/** The generated introspection artifact's filename, written under its resolved directory. */
|
|
111
123
|
const INTROSPECTION_FILE = "introspection.ts";
|
|
@@ -290,6 +302,16 @@ async function resolveWatchCredentials(cwd) {
|
|
|
290
302
|
if (!wordPressConnectionComplete(env)) return void 0;
|
|
291
303
|
return resolveWordPressConnection(env).credentials;
|
|
292
304
|
}
|
|
305
|
+
function resolveMcp(fileConfig) {
|
|
306
|
+
return {
|
|
307
|
+
port: fileConfig?.mcp?.port ?? DEFAULT_MCP_PORT,
|
|
308
|
+
portExplicit: fileConfig?.mcp?.port !== void 0
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Resolve `mcp` from `kizlo.config.*`, applying the defaults for a project that says nothing about it. */
|
|
312
|
+
async function resolveMcpConfig(cwd) {
|
|
313
|
+
return resolveMcp(await loadConfigFile(findConfigDir(cwd)));
|
|
314
|
+
}
|
|
293
315
|
/**
|
|
294
316
|
* Resolve the dev stack (`local.dev`) into concrete values for the `dev` command, applying defaults
|
|
295
317
|
* (port 8080). The install folder is fixed at `.kizlo/local` (no longer a config choice), so there's
|
|
@@ -298,7 +320,8 @@ async function resolveWatchCredentials(cwd) {
|
|
|
298
320
|
*/
|
|
299
321
|
async function resolveDevConfig(cwd) {
|
|
300
322
|
const configDir = findConfigDir(cwd);
|
|
301
|
-
const
|
|
323
|
+
const fileConfig = await loadConfigFile(configDir);
|
|
324
|
+
const local = resolveLocal(fileConfig);
|
|
302
325
|
const { dev } = local;
|
|
303
326
|
return {
|
|
304
327
|
configDir,
|
|
@@ -313,9 +336,10 @@ async function resolveDevConfig(cwd) {
|
|
|
313
336
|
wordpressTag: dev.version ?? DEFAULT_WORDPRESS_TAG,
|
|
314
337
|
fixtures: dev.fixtures ?? [],
|
|
315
338
|
wordpressPath: LOCAL_DIR_REL,
|
|
316
|
-
wordpressDir: path.resolve(configDir, LOCAL_DIR_REL)
|
|
339
|
+
wordpressDir: path.resolve(configDir, LOCAL_DIR_REL),
|
|
340
|
+
mcp: resolveMcp(fileConfig)
|
|
317
341
|
};
|
|
318
342
|
}
|
|
319
343
|
|
|
320
344
|
//#endregion
|
|
321
|
-
export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, resolveWatchCredentials, stackProject, usesLocalWordPress };
|
|
345
|
+
export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, DEFAULT_MCP_PORT, resolveConfig, resolveDevConfig, resolveMcpConfig, resolveStackName, resolveTestConfig, resolveWatchCredentials, stackProject, usesLocalWordPress };
|
|
@@ -178,7 +178,7 @@ function reportPluginVersion(installed) {
|
|
|
178
178
|
async function fetchDocument(cwd, options, etag) {
|
|
179
179
|
loadEnvFiles(cwd);
|
|
180
180
|
const result = await fetchIntrospection(options.credentials ?? resolveWordPressConnection(integrationEnv([node()])).credentials, {
|
|
181
|
-
etag: options.strict ? void 0
|
|
181
|
+
etag: !options.strict && !options.seed ? etag : void 0,
|
|
182
182
|
fetch: options.fetch
|
|
183
183
|
});
|
|
184
184
|
reportPluginVersion(result.pluginVersion);
|
|
@@ -208,8 +208,14 @@ async function generateIntrospectionOnce(cfg, options = {}) {
|
|
|
208
208
|
if (options.skipIntrospection) return "skipped";
|
|
209
209
|
const file = path.resolve(cfg.cwd, cfg.introspectionPath);
|
|
210
210
|
const metaPath = path.resolve(cfg.cwd, cfg.introspectionMetaPath);
|
|
211
|
-
const
|
|
211
|
+
const meta = readWordPressMeta(metaPath, file);
|
|
212
|
+
const result = await fetchDocument(cfg.cwd, options, meta?.etag);
|
|
212
213
|
if (!result?.document) return "unchanged";
|
|
214
|
+
options.onDocument?.(result.document);
|
|
215
|
+
if (options.seed && meta && result.document.hash === meta.hash) {
|
|
216
|
+
if (result.etag !== meta.etag) atomicWrite(metaPath, wordPressMeta(result.etag, result.document.hash));
|
|
217
|
+
return "unchanged";
|
|
218
|
+
}
|
|
213
219
|
const generated = generateWordPressModule(result.document);
|
|
214
220
|
await assertGeneratedClientCompiles(generated);
|
|
215
221
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
package/dist/cli/daemon/watch.js
CHANGED
|
@@ -103,8 +103,8 @@ function createWordPressRefresh(cfg, options, stack) {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
/** Run {@link createWordPressRefresh} on a timer, skipping a tick while the previous one is still going. */
|
|
106
|
-
function refreshWordPress(cfg,
|
|
107
|
-
const refresh = createWordPressRefresh(cfg,
|
|
106
|
+
function refreshWordPress(cfg, options, stack) {
|
|
107
|
+
const refresh = createWordPressRefresh(cfg, options, stack);
|
|
108
108
|
let refreshing = false;
|
|
109
109
|
const timer = setInterval(() => {
|
|
110
110
|
if (refreshing) return;
|
|
@@ -139,6 +139,14 @@ async function startWatcher(cwd, opts) {
|
|
|
139
139
|
const credentials = await resolveWatchCredentials(cwd);
|
|
140
140
|
const options = credentials ? { credentials } : { skipIntrospection: true };
|
|
141
141
|
if (!credentials) log.info("No WordPress connection configured; running the contract watcher only, introspection skipped.");
|
|
142
|
+
if (opts?.mcp) {
|
|
143
|
+
opts.mcp.credentials = credentials;
|
|
144
|
+
options.seed = true;
|
|
145
|
+
options.onDocument = (document) => {
|
|
146
|
+
if (opts.mcp) opts.mcp.document = document;
|
|
147
|
+
options.seed = false;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
142
150
|
try {
|
|
143
151
|
const result = await generateOnce(cfg, options);
|
|
144
152
|
if (result.contract === "built") log.success("Contract generated");
|
|
@@ -148,7 +156,7 @@ async function startWatcher(cwd, opts) {
|
|
|
148
156
|
reportGenerationError("Failed to generate the Kizlo contract:", error);
|
|
149
157
|
}
|
|
150
158
|
const watcher = cfg.server ? await watch(cfg, cfg.server, options) : void 0;
|
|
151
|
-
const wordpressRefresh = credentials ? refreshWordPress(cfg,
|
|
159
|
+
const wordpressRefresh = credentials ? refreshWordPress(cfg, options, opts?.stack) : void 0;
|
|
152
160
|
let stopped = false;
|
|
153
161
|
handedOver = true;
|
|
154
162
|
return () => {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { log } from "../daemon/logger.js";
|
|
2
|
+
import { pickStackPort } from "../utils.js";
|
|
3
|
+
import { MCP_HOST, MCP_PATH, isAllowedOrigin, startMcpServer } from "./server.js";
|
|
4
|
+
import { createMcpState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
//#region src/cli/mcp/index.ts
|
|
7
|
+
/**
|
|
8
|
+
* The port the server will bind, stepping off a collision unless the project pinned one.
|
|
9
|
+
*
|
|
10
|
+
* Resolved before the server starts so the dev summary can print the address alongside WordPress's.
|
|
11
|
+
* A pinned port that is taken stops the command the way `dev.port` and `test.port` do: the developer
|
|
12
|
+
* wrote that number, a harness config elsewhere names it, and quietly serving on a different one
|
|
13
|
+
* would leave the harness connecting to nothing.
|
|
14
|
+
*/
|
|
15
|
+
async function resolveMcpPort(config) {
|
|
16
|
+
return pickStackPort(config.port, {
|
|
17
|
+
fixed: config.portExplicit,
|
|
18
|
+
host: MCP_HOST,
|
|
19
|
+
configKey: "mcp.port"
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Start the MCP server for a session that has a watcher, or say why it is not running.
|
|
24
|
+
*
|
|
25
|
+
* Called after the watcher starts and never inside its restart cycle. Two cases leave it off, and
|
|
26
|
+
* neither is a failure of the session: another `kizlo dev` already holds the watcher lock and is
|
|
27
|
+
* serving MCP itself (the caller skips this entirely), or there is no WordPress connection to expose,
|
|
28
|
+
* where the contract watcher still has work to do and MCP has nothing to answer with.
|
|
29
|
+
*/
|
|
30
|
+
async function startMcp(state, port) {
|
|
31
|
+
if (!state.credentials) {
|
|
32
|
+
log.info("No WordPress connection configured; the MCP server is off for this session.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const handle = await startMcpServer(state, { port });
|
|
37
|
+
log.success(`MCP server on ${handle.url}`);
|
|
38
|
+
return handle;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
log.error("Failed to start the MCP server:", error);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { resolveMcpPort, startMcp };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { camel, endpointDefinition } from "../../wordpress/generate.js";
|
|
2
|
+
|
|
3
|
+
//#region src/cli/mcp/routes.ts
|
|
4
|
+
/**
|
|
5
|
+
* Every operation in the document, keyed by route name.
|
|
6
|
+
*
|
|
7
|
+
* Built per call rather than cached: the watcher replaces the document whenever WordPress moves, and
|
|
8
|
+
* a registry that outlived one would answer for routes that no longer exist while missing the ones
|
|
9
|
+
* that just appeared. Walking a contract of a few hundred routes is far cheaper than getting that wrong.
|
|
10
|
+
*/
|
|
11
|
+
function routesOf(document) {
|
|
12
|
+
const routes = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const [apiId, api] of Object.entries(document.apis)) {
|
|
14
|
+
const prefix = apiId.split(".").map(camel);
|
|
15
|
+
for (const [path, operations] of Object.entries(api.paths)) for (const [operationId, operation] of Object.entries(operations)) {
|
|
16
|
+
const name = [...prefix, camel(operationId)].join(".");
|
|
17
|
+
routes.set(name, {
|
|
18
|
+
name,
|
|
19
|
+
namespace: api.namespace,
|
|
20
|
+
method: operation.method,
|
|
21
|
+
path,
|
|
22
|
+
...operation.summary !== void 0 ? { summary: operation.summary } : {},
|
|
23
|
+
...operation.description !== void 0 ? { description: operation.description } : {},
|
|
24
|
+
...operation.deprecated !== void 0 ? { deprecated: operation.deprecated } : {},
|
|
25
|
+
operation,
|
|
26
|
+
definition: endpointDefinition({
|
|
27
|
+
namespace: api.namespace,
|
|
28
|
+
path,
|
|
29
|
+
operation
|
|
30
|
+
})
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return routes;
|
|
35
|
+
}
|
|
36
|
+
/** Route names in the order a listing shows them, so two calls against one document agree. */
|
|
37
|
+
function sortRoutes(routes) {
|
|
38
|
+
return [...routes].sort((left, right) => left.name.localeCompare(right.name));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
export { routesOf, sortRoutes };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//#region src/cli/mcp/schema.ts
|
|
2
|
+
/**
|
|
3
|
+
* Turn an introspection schema into the JSON Schema an MCP client validates a tool call against.
|
|
4
|
+
*
|
|
5
|
+
* Three things separate the two dialects. Introspection marks a field required on the field itself,
|
|
6
|
+
* where JSON Schema lists the names on the object holding them. `$ref` and `$extends` name entries in
|
|
7
|
+
* `document.schemas` rather than being JSON pointers, and a client that has only the tool's schema
|
|
8
|
+
* cannot follow either, so both are resolved and inlined here. And `file` is not a JSON Schema type;
|
|
9
|
+
* it becomes the `string`/`binary` pair OpenAPI uses for the same thing.
|
|
10
|
+
*/
|
|
11
|
+
function toJsonSchema(schema, document) {
|
|
12
|
+
return convert(schema, document, /* @__PURE__ */ new Set());
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A schema is its own keywords laid over whatever it references. `$ref` names the whole shape and
|
|
16
|
+
* `$extends` names shapes to build on, so both resolve to bases and the schema's own keywords win —
|
|
17
|
+
* which is what lets `acme.book` extend `acme.entity` and still redeclare a property it inherits.
|
|
18
|
+
*
|
|
19
|
+
* `seen` carries the ids already being resolved on this branch. A contract is free to describe a
|
|
20
|
+
* schema that reaches itself, and without this that is an infinite recursion rather than a document.
|
|
21
|
+
*/
|
|
22
|
+
function convert(schema, document, seen) {
|
|
23
|
+
let result = own(schema, document, seen);
|
|
24
|
+
for (const id of [...schema.$ref ? [schema.$ref] : [], ...names(schema.$extends)]) result = merge(reference(id, document, seen), result);
|
|
25
|
+
return schema.nullable ? widenToNull(result) : result;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Let a converted schema accept `null`. A type takes it in place; a schema whose shape came from a
|
|
29
|
+
* `$ref` or a combinator has no type of its own to widen, so the union carries it instead.
|
|
30
|
+
*/
|
|
31
|
+
function widenToNull(schema) {
|
|
32
|
+
const type = schema.type;
|
|
33
|
+
if (typeof type === "string") return {
|
|
34
|
+
...schema,
|
|
35
|
+
type: [type, "null"]
|
|
36
|
+
};
|
|
37
|
+
if (Array.isArray(type)) return type.includes("null") ? schema : {
|
|
38
|
+
...schema,
|
|
39
|
+
type: [...type, "null"]
|
|
40
|
+
};
|
|
41
|
+
return { anyOf: [schema, { type: "null" }] };
|
|
42
|
+
}
|
|
43
|
+
/** Resolve one `document.schemas` entry, or an empty schema when it is absent or already on this branch. */
|
|
44
|
+
function reference(id, document, seen) {
|
|
45
|
+
const target = document.schemas[id];
|
|
46
|
+
if (!target || seen.has(id)) return {};
|
|
47
|
+
return convert(target, document, new Set([...seen, id]));
|
|
48
|
+
}
|
|
49
|
+
function names(value) {
|
|
50
|
+
if (value === void 0) return [];
|
|
51
|
+
return Array.isArray(value) ? value : [value];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Lay `own` over `base`. Only the two keywords that describe an object's members combine: everything
|
|
55
|
+
* else is a plain override, since a schema restating a `type` or a `description` means to replace it.
|
|
56
|
+
*/
|
|
57
|
+
function merge(base, own$1) {
|
|
58
|
+
const result = {
|
|
59
|
+
...base,
|
|
60
|
+
...own$1
|
|
61
|
+
};
|
|
62
|
+
const properties = {
|
|
63
|
+
...base.properties,
|
|
64
|
+
...own$1.properties
|
|
65
|
+
};
|
|
66
|
+
if (Object.keys(properties).length > 0) result.properties = properties;
|
|
67
|
+
const required = [...new Set([...base.required ?? [], ...own$1.required ?? []])];
|
|
68
|
+
if (required.length > 0) result.required = required;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** The schema's own keywords, with the references it is built from left to {@link convert}. */
|
|
72
|
+
function own(schema, document, seen) {
|
|
73
|
+
const result = {};
|
|
74
|
+
if (schema.type === "file") {
|
|
75
|
+
result.type = "string";
|
|
76
|
+
result.format = "binary";
|
|
77
|
+
} else if (schema.type) result.type = schema.type;
|
|
78
|
+
if (schema.title !== void 0) result.title = schema.title;
|
|
79
|
+
if (schema.description !== void 0) result.description = schema.description;
|
|
80
|
+
if (schema.deprecated !== void 0) result.deprecated = schema.deprecated;
|
|
81
|
+
if (schema.default !== void 0) result.default = schema.default;
|
|
82
|
+
if (schema.enum !== void 0) result.enum = schema.enum;
|
|
83
|
+
if (schema.format !== void 0) result.format = schema.format;
|
|
84
|
+
if (schema.properties) {
|
|
85
|
+
const properties = {};
|
|
86
|
+
const required = [];
|
|
87
|
+
for (const [name, child] of Object.entries(schema.properties)) {
|
|
88
|
+
properties[name] = convert(child, document, seen);
|
|
89
|
+
if (child.required) required.push(name);
|
|
90
|
+
}
|
|
91
|
+
result.properties = properties;
|
|
92
|
+
if (required.length > 0) result.required = required;
|
|
93
|
+
}
|
|
94
|
+
if (schema.items) result.items = convert(schema.items, document, seen);
|
|
95
|
+
if (schema.anyOf) result.anyOf = schema.anyOf.map((member) => convert(member, document, seen));
|
|
96
|
+
if (schema.oneOf) result.oneOf = schema.oneOf.map((member) => convert(member, document, seen));
|
|
97
|
+
if (typeof schema.additionalProperties === "boolean") result.additionalProperties = schema.additionalProperties;
|
|
98
|
+
else if (schema.additionalProperties) result.additionalProperties = convert(schema.additionalProperties, document, seen);
|
|
99
|
+
if (schema.patternProperties) result.patternProperties = Object.fromEntries(Object.entries(schema.patternProperties).map(([pattern, child]) => [pattern, convert(child, document, seen)]));
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
export { toJsonSchema };
|