@mediamonks/canopy-cli 0.7.30 → 0.7.32
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/{ai-fAvjdjVX.mjs → ai-DHLnyqfq.mjs} +2 -1
- package/dist/{ui-CeupNHNa.mjs → api-C4rMhE7i.mjs} +1 -122
- package/dist/{db-KTLn8J1u.mjs → db-BB6O6085.mjs} +3 -2
- package/dist/{deploy-CBf44bvG.mjs → deploy-Ka2lHkyy.mjs} +114 -2
- package/dist/{dev-JlzMZJow.mjs → dev-CkhBnSlv.mjs} +13 -6
- package/dist/dev-MoHHjQh1.mjs +2 -0
- package/dist/dev-server-file-Dk9pk4AR.mjs +54 -0
- package/dist/{dev-worker-DjJGSUGj.mjs → dev-worker-CaCsGBkn.mjs} +34 -2
- package/dist/docs/agent-tools.md +212 -0
- package/dist/docs/cli-mcp.md +88 -0
- package/dist/docs/developers-agents-and-ai.md +8 -0
- package/dist/docs/index.json +15 -0
- package/dist/docs/sdk-mcp.md +222 -0
- package/dist/{docs-Dg0UJugG.mjs → docs-CtNH5kbN.mjs} +2 -2
- package/dist/{egress-Cn9zDfDT.mjs → egress-If_Gsx_M.mjs} +2 -1
- package/dist/{feedback-Dvb6CdlP.mjs → feedback-_L3vqktY.mjs} +3 -2
- package/dist/index.mjs +32 -29
- package/dist/{local-BA_TPWpN.mjs → local-DAYCNUyz.mjs} +3 -3
- package/dist/{login-MX0xq8UE.mjs → login-B-m-i8uu.mjs} +1 -1
- package/dist/{logs-D5RWqsWl.mjs → logs-DQQOawOG.mjs} +2 -1
- package/dist/mcp-RcN17Z2v.mjs +457 -0
- package/dist/{migrations-C8yRPJIX.mjs → migrations-Czlcddyz.mjs} +1 -1
- package/dist/{open-BJgd0R61.mjs → open-DcaQKi1v.mjs} +1 -1
- package/dist/{package-CnS450Yc.mjs → package-YGjjin4p.mjs} +1 -1
- package/dist/{projects-BG7Injpb.mjs → projects-CN2YhCZn.mjs} +2 -1
- package/dist/{schedule-CLuksxSM.mjs → schedule-C5lMf3ny.mjs} +2 -1
- package/dist/{secrets-Bl3uOx4l.mjs → secrets-BSEjZnxg.mjs} +2 -1
- package/dist/skill/canopy/SKILL.md +7 -0
- package/dist/{skill-CuES7kPj.mjs → skill-CM7R-4Bq.mjs} +2 -2
- package/dist/{src-C7zPhbxi.mjs → src-BkACMkol.mjs} +15 -1
- package/dist/{staging-D06zobnf.mjs → staging-BZVty57b.mjs} +2 -1
- package/dist/templates/spa/package.json +1 -1
- package/dist/templates/worker/package.json +1 -1
- package/dist/templates/worker/worker/index.ts +17 -0
- package/dist/{token-C_PFNqcv.mjs → token-HHlUR0CF.mjs} +2 -1
- package/dist/ui-C_jSc1Kz.mjs +123 -0
- package/dist/{upgrade-C8YdSKKd.mjs → upgrade-ChcSZW8i.mjs} +2 -2
- package/dist/{usage-CV7TjTO8.mjs → usage-CUYrWxv0.mjs} +2 -1
- package/package.json +5 -2
- package/dist/dev-DGCu9MjB.mjs +0 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as apiRequest, o as withContext } from "./api-C4rMhE7i.mjs";
|
|
2
|
+
import { r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
2
3
|
import { defineCommand } from "citty";
|
|
3
4
|
//#region src/ai.ts
|
|
4
5
|
/**
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { n as siteConfig } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { cancel, isCancel } from "@clack/prompts";
|
|
4
2
|
//#region src/api.ts
|
|
5
3
|
/**
|
|
6
4
|
* Authenticated requests to the Canopy control plane. Credentials, in order:
|
|
@@ -340,123 +338,4 @@ async function apiDataRequest(method, path, projectSlug, body, rawContentType, c
|
|
|
340
338
|
return await response.json();
|
|
341
339
|
}
|
|
342
340
|
//#endregion
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Shared terminal UX plumbing: clack prompt unwrapping and uniform
|
|
346
|
-
* command error reporting.
|
|
347
|
-
*/
|
|
348
|
-
/** Unwrap a clack prompt result, exiting cleanly when the user cancels (ctrl-c). */
|
|
349
|
-
function unwrap(result) {
|
|
350
|
-
if (isCancel(result)) {
|
|
351
|
-
cancel("Aborted.");
|
|
352
|
-
process.exit(130);
|
|
353
|
-
}
|
|
354
|
-
return result;
|
|
355
|
-
}
|
|
356
|
-
/** Terminal width to wrap error copy to, bounded so it stays readable. */
|
|
357
|
-
const TEXT_WIDTH = Math.min(process.stdout.columns || 80, 88);
|
|
358
|
-
/** Render ` Label text…`, wrapping and aligning continuation lines under the text. */
|
|
359
|
-
function labeled(label, text) {
|
|
360
|
-
const lead = ` ${label} `;
|
|
361
|
-
const indent = " ".repeat(lead.length);
|
|
362
|
-
const max = Math.max(TEXT_WIDTH - lead.length, 24);
|
|
363
|
-
const lines = [];
|
|
364
|
-
let line = "";
|
|
365
|
-
for (const word of text.split(/\s+/)) if (line && line.length + 1 + word.length > max) {
|
|
366
|
-
lines.push(line);
|
|
367
|
-
line = word;
|
|
368
|
-
} else line = line ? `${line} ${word}` : word;
|
|
369
|
-
if (line) lines.push(line);
|
|
370
|
-
return lines.map((l, i) => (i === 0 ? lead : indent) + l).join("\n");
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Compose a command failure. API failures get a three-part block — what we
|
|
374
|
-
* tried (headline), why it failed, and how to fix it — everything else prints
|
|
375
|
-
* as a single `canopy <name>: <message>` line.
|
|
376
|
-
*/
|
|
377
|
-
function formatCliError(name, err) {
|
|
378
|
-
if (err instanceof ApiError) {
|
|
379
|
-
const { headline, why, fix } = err.explain();
|
|
380
|
-
return [
|
|
381
|
-
`canopy ${name}: ${headline}`,
|
|
382
|
-
"",
|
|
383
|
-
labeled("Why", why),
|
|
384
|
-
labeled("Fix", fix),
|
|
385
|
-
""
|
|
386
|
-
].join("\n");
|
|
387
|
-
}
|
|
388
|
-
return `canopy ${name}: ${err instanceof Error ? err.message : String(err)}`;
|
|
389
|
-
}
|
|
390
|
-
const kebab = (s) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
391
|
-
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
392
|
-
/**
|
|
393
|
-
* Reject flags a command does not declare. citty parses with strict: false, so
|
|
394
|
-
* a misspelled or not-yet-released flag would otherwise be silently dropped —
|
|
395
|
-
* on `deploy --staging` (pre-0.7) that meant deploying to the wrong channel.
|
|
396
|
-
*/
|
|
397
|
-
function assertKnownFlags(name, rawArgs, argDefs) {
|
|
398
|
-
const known = /* @__PURE__ */ new Set([
|
|
399
|
-
"help",
|
|
400
|
-
"h",
|
|
401
|
-
"version",
|
|
402
|
-
"profile"
|
|
403
|
-
]);
|
|
404
|
-
for (const [key, def] of Object.entries(argDefs)) {
|
|
405
|
-
if (def.type === "positional") continue;
|
|
406
|
-
known.add(key);
|
|
407
|
-
known.add(kebab(key));
|
|
408
|
-
known.add(camel(key));
|
|
409
|
-
const aliases = typeof def.alias === "string" ? [def.alias] : def.alias ?? [];
|
|
410
|
-
for (const a of aliases) known.add(a);
|
|
411
|
-
if (def.type === "boolean") {
|
|
412
|
-
known.add(`no-${key}`);
|
|
413
|
-
known.add(`no-${kebab(key)}`);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
for (const token of rawArgs) {
|
|
417
|
-
if (token === "--") break;
|
|
418
|
-
if (!token.startsWith("-") || token === "-") continue;
|
|
419
|
-
const body = token.replace(/^--?/, "").split("=")[0] ?? "";
|
|
420
|
-
if (!body || /^\d/.test(body)) continue;
|
|
421
|
-
if (!known.has(body)) throw new Error(`Unknown flag --${body}. Run \`canopy ${name} --help\` to see supported flags — if the docs mention it, your CLI may be outdated (\`canopy upgrade\`).`);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
/** Wrap a command action so failures print an actionable report and exit 1. */
|
|
425
|
-
function reportErrors(name, action) {
|
|
426
|
-
return async (...args) => {
|
|
427
|
-
try {
|
|
428
|
-
const ctx = args[0];
|
|
429
|
-
if (ctx?.rawArgs && ctx.cmd?.args) assertKnownFlags(name, ctx.rawArgs, ctx.cmd.args);
|
|
430
|
-
await action(...args);
|
|
431
|
-
} catch (err) {
|
|
432
|
-
console.error(formatCliError(name, err));
|
|
433
|
-
process.exit(1);
|
|
434
|
-
}
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
/** Guard interactive prompts: bail with a actionable message on non-TTY runs. */
|
|
438
|
-
function requireTty(hint) {
|
|
439
|
-
if (!process.stdin.isTTY) throw new Error(`This needs an interactive terminal — ${hint}`);
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* API timestamps are UTC — ISO with a Z suffix, though a lagging API site may
|
|
443
|
-
* still send the bare "YYYY-MM-DD HH:MM:SS" form. Parse either as UTC and
|
|
444
|
-
* render compact local wall-clock time ("2026-08-25 14:23").
|
|
445
|
-
*/
|
|
446
|
-
function formatDateTime(raw) {
|
|
447
|
-
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw);
|
|
448
|
-
const date = new Date(hasZone ? raw : raw.replace(" ", "T") + "Z");
|
|
449
|
-
if (Number.isNaN(date.getTime())) return raw;
|
|
450
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
451
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
452
|
-
}
|
|
453
|
-
/** Open a URL in the default browser, fire-and-forget. */
|
|
454
|
-
function openBrowser(url) {
|
|
455
|
-
spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open", [url], {
|
|
456
|
-
stdio: "ignore",
|
|
457
|
-
detached: true,
|
|
458
|
-
shell: process.platform === "win32"
|
|
459
|
-
}).on("error", () => {}).unref();
|
|
460
|
-
}
|
|
461
|
-
//#endregion
|
|
462
|
-
export { unwrap as a, apiDownload as c, withContext as d, requireTty as i, apiRequest as l, openBrowser as n, ApiError as o, reportErrors as r, apiDataRequest as s, formatDateTime as t, apiUpload as u };
|
|
341
|
+
export { apiUpload as a, apiRequest as i, apiDataRequest as n, withContext as o, apiDownload as r, ApiError as t };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { i as apiRequest, n as apiDataRequest, o as withContext } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
4
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Czlcddyz.mjs";
|
|
4
5
|
import { defineCommand } from "citty";
|
|
5
6
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
7
|
import * as p from "@clack/prompts";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { i as apiRequest, n as apiDataRequest, o as withContext, r as apiDownload } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
4
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Czlcddyz.mjs";
|
|
5
|
+
import { builtinModules } from "node:module";
|
|
4
6
|
import { defineCommand } from "citty";
|
|
5
7
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { tmpdir } from "node:os";
|
|
@@ -26,6 +28,7 @@ async function bundleWorker(projectDir, entry) {
|
|
|
26
28
|
format: "esm",
|
|
27
29
|
target: "es2022",
|
|
28
30
|
packages: "bundle",
|
|
31
|
+
plugins: [nodeBuiltins],
|
|
29
32
|
logLevel: "silent"
|
|
30
33
|
});
|
|
31
34
|
return new Uint8Array(readFileSync(outfile));
|
|
@@ -36,6 +39,115 @@ async function bundleWorker(projectDir, entry) {
|
|
|
36
39
|
});
|
|
37
40
|
}
|
|
38
41
|
}
|
|
42
|
+
/** Bare names Node resolves to a built-in (`fs`, `fs/promises`, …). */
|
|
43
|
+
const BARE_BUILTINS = new Set(builtinModules.filter((name) => !name.startsWith("_")));
|
|
44
|
+
/**
|
|
45
|
+
* Built-ins both hosts provide, each with a default export: Node, and workerd
|
|
46
|
+
* at the dispatch scripts' compatibility date (2026-05-01, nodejs_compat —
|
|
47
|
+
* probed with a worker importing every one; several are stubs that throw only
|
|
48
|
+
* when called). Missing on workerd: `sea`, `test/reporters`. Left out on
|
|
49
|
+
* purpose: `sqlite` (experimental on Node) and `test`.
|
|
50
|
+
*/
|
|
51
|
+
const HOSTED_BUILTINS = /* @__PURE__ */ new Set([
|
|
52
|
+
"assert",
|
|
53
|
+
"assert/strict",
|
|
54
|
+
"async_hooks",
|
|
55
|
+
"buffer",
|
|
56
|
+
"child_process",
|
|
57
|
+
"cluster",
|
|
58
|
+
"console",
|
|
59
|
+
"constants",
|
|
60
|
+
"crypto",
|
|
61
|
+
"dgram",
|
|
62
|
+
"diagnostics_channel",
|
|
63
|
+
"dns",
|
|
64
|
+
"dns/promises",
|
|
65
|
+
"domain",
|
|
66
|
+
"events",
|
|
67
|
+
"fs",
|
|
68
|
+
"fs/promises",
|
|
69
|
+
"http",
|
|
70
|
+
"http2",
|
|
71
|
+
"https",
|
|
72
|
+
"inspector",
|
|
73
|
+
"inspector/promises",
|
|
74
|
+
"module",
|
|
75
|
+
"net",
|
|
76
|
+
"os",
|
|
77
|
+
"path",
|
|
78
|
+
"path/posix",
|
|
79
|
+
"path/win32",
|
|
80
|
+
"perf_hooks",
|
|
81
|
+
"process",
|
|
82
|
+
"punycode",
|
|
83
|
+
"querystring",
|
|
84
|
+
"readline",
|
|
85
|
+
"readline/promises",
|
|
86
|
+
"repl",
|
|
87
|
+
"stream",
|
|
88
|
+
"stream/consumers",
|
|
89
|
+
"stream/promises",
|
|
90
|
+
"stream/web",
|
|
91
|
+
"string_decoder",
|
|
92
|
+
"sys",
|
|
93
|
+
"timers",
|
|
94
|
+
"timers/promises",
|
|
95
|
+
"tls",
|
|
96
|
+
"trace_events",
|
|
97
|
+
"tty",
|
|
98
|
+
"url",
|
|
99
|
+
"util",
|
|
100
|
+
"util/types",
|
|
101
|
+
"v8",
|
|
102
|
+
"vm",
|
|
103
|
+
"wasi",
|
|
104
|
+
"worker_threads",
|
|
105
|
+
"zlib"
|
|
106
|
+
]);
|
|
107
|
+
const REQUIRE_NAMESPACE = "canopy-node-require";
|
|
108
|
+
/**
|
|
109
|
+
* Node built-ins are the host's, not the bundle's: the GCP runtime is Node and
|
|
110
|
+
* Cloudflare dispatch scripts run with nodejs_compat. A `neutral` build cannot
|
|
111
|
+
* resolve them at all, so every import of one — `node:fs` or bare `fs`, as Node
|
|
112
|
+
* itself treats them — stays external under its `node:` name.
|
|
113
|
+
*
|
|
114
|
+
* A CommonJS `require()` of one cannot simply stay external: in an ESM bundle
|
|
115
|
+
* esbuild turns it into a `__require` shim that throws "Dynamic require of …
|
|
116
|
+
* is not supported" when it runs. Neither host offers a `require` to an ES
|
|
117
|
+
* module, so the call is routed to a tiny ES module that re-exports the
|
|
118
|
+
* built-in through a static `import`. The AI SDK needs this: `ai` reaches
|
|
119
|
+
* `@vercel/oidc`, whose CommonJS build requires `fs`, `os` and `path` at load.
|
|
120
|
+
*
|
|
121
|
+
* That turns a lazy require — often an optional one in a try/catch — into an
|
|
122
|
+
* import made at load, and Cloudflare refuses a script importing a module it
|
|
123
|
+
* lacks. So only HOSTED_BUILTINS are rewritten; a require of any other
|
|
124
|
+
* built-in keeps the throwing shim, which fails only if that line runs.
|
|
125
|
+
*/
|
|
126
|
+
const nodeBuiltins = {
|
|
127
|
+
name: "canopy-node-builtins",
|
|
128
|
+
setup(build) {
|
|
129
|
+
build.onResolve({ filter: /^(node:)?[a-z_][a-z0-9_/]*$/ }, (args) => {
|
|
130
|
+
const prefixed = args.path.startsWith("node:");
|
|
131
|
+
const name = prefixed ? args.path.slice(5) : args.path;
|
|
132
|
+
if (!prefixed && !BARE_BUILTINS.has(name)) return void 0;
|
|
133
|
+
if (args.kind === "require-call" && HOSTED_BUILTINS.has(name)) return {
|
|
134
|
+
path: name,
|
|
135
|
+
namespace: REQUIRE_NAMESPACE
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
path: `node:${name}`,
|
|
139
|
+
external: true
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
build.onLoad({
|
|
143
|
+
filter: /.*/,
|
|
144
|
+
namespace: REQUIRE_NAMESPACE
|
|
145
|
+
}, (args) => ({
|
|
146
|
+
contents: `export * from "node:${args.path}";\nexport { default } from "node:${args.path}";\n`,
|
|
147
|
+
loader: "js"
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
39
151
|
//#endregion
|
|
40
152
|
//#region src/deploy.ts
|
|
41
153
|
/**
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { r as
|
|
4
|
-
import {
|
|
2
|
+
import { i as apiRequest, o as withContext, t as ApiError } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
4
|
+
import { r as readMigrationFiles } from "./migrations-Czlcddyz.mjs";
|
|
5
|
+
import { r as stampWorkerHeaders } from "./src-BkACMkol.mjs";
|
|
6
|
+
import { n as writeDevServerRecord } from "./dev-server-file-Dk9pk4AR.mjs";
|
|
5
7
|
import { createRequire } from "node:module";
|
|
6
8
|
import { defineCommand } from "citty";
|
|
7
9
|
import { existsSync } from "node:fs";
|
|
@@ -38,7 +40,7 @@ async function workerPlugin(flags, slug) {
|
|
|
38
40
|
console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
|
|
39
41
|
return;
|
|
40
42
|
}
|
|
41
|
-
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-
|
|
43
|
+
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CaCsGBkn.mjs");
|
|
42
44
|
const secrets = readDevVars(flags.dir);
|
|
43
45
|
const email = siteConfig().email;
|
|
44
46
|
const plugin = workerDevPlugin({
|
|
@@ -120,6 +122,11 @@ async function startVite(flags, serverOverrides, extraPlugins = []) {
|
|
|
120
122
|
}
|
|
121
123
|
});
|
|
122
124
|
await server.listen();
|
|
125
|
+
const local = server.resolvedUrls?.local[0];
|
|
126
|
+
if (local) {
|
|
127
|
+
const remove = writeDevServerRecord(flags.dir, new URL(local).origin);
|
|
128
|
+
server.httpServer?.once("close", remove);
|
|
129
|
+
}
|
|
123
130
|
return server;
|
|
124
131
|
}
|
|
125
132
|
/**
|
|
@@ -218,7 +225,7 @@ async function devRemote(flags, slug) {
|
|
|
218
225
|
async function devLocal(flags, slug) {
|
|
219
226
|
let dump;
|
|
220
227
|
if (flags.pull) {
|
|
221
|
-
const { fetchProjectDump } = await import("./local-
|
|
228
|
+
const { fetchProjectDump } = await import("./local-DAYCNUyz.mjs");
|
|
222
229
|
console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
|
|
223
230
|
dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
|
|
224
231
|
}
|
|
@@ -233,7 +240,7 @@ async function devLocal(flags, slug) {
|
|
|
233
240
|
const reason = err instanceof ApiError ? err.explain().why : String(err);
|
|
234
241
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
|
|
235
242
|
}
|
|
236
|
-
const { createSandbox } = await import("./local-
|
|
243
|
+
const { createSandbox } = await import("./local-DAYCNUyz.mjs");
|
|
237
244
|
const sandbox = await createSandbox({
|
|
238
245
|
slug,
|
|
239
246
|
persistDir: join(flags.dir, ".canopy", "local"),
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
//#region src/dev-server-file.ts
|
|
4
|
+
/**
|
|
5
|
+
* Where a running `canopy dev` can be reached, for `canopy mcp serve --local`
|
|
6
|
+
* (docs/49): `.canopy/dev-server.json` in the project directory, written once
|
|
7
|
+
* the dev server listens and removed when it stops. The pid lets a reader
|
|
8
|
+
* tell a live server from a file a crash left behind.
|
|
9
|
+
*/
|
|
10
|
+
const devServerFile = (dir) => join(dir, ".canopy", "dev-server.json");
|
|
11
|
+
function writeDevServerRecord(dir, url) {
|
|
12
|
+
const path = devServerFile(dir);
|
|
13
|
+
mkdirSync(join(dir, ".canopy"), { recursive: true });
|
|
14
|
+
writeFileSync(path, JSON.stringify({
|
|
15
|
+
url,
|
|
16
|
+
pid: process.pid
|
|
17
|
+
}) + "\n");
|
|
18
|
+
let removed = false;
|
|
19
|
+
const remove = () => {
|
|
20
|
+
if (removed) return;
|
|
21
|
+
removed = true;
|
|
22
|
+
if (readDevServerRecord(dir)?.pid === process.pid) rmSync(path, { force: true });
|
|
23
|
+
};
|
|
24
|
+
process.once("exit", remove);
|
|
25
|
+
return remove;
|
|
26
|
+
}
|
|
27
|
+
function readDevServerRecord(dir) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(devServerFile(dir), "utf8"));
|
|
30
|
+
if (typeof parsed.url !== "string" || typeof parsed.pid !== "number") return null;
|
|
31
|
+
return {
|
|
32
|
+
url: parsed.url,
|
|
33
|
+
pid: parsed.pid
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Whether a process with this pid exists (signal 0 checks without sending). */
|
|
40
|
+
function processAlive(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return err.code === "EPERM";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The running dev server for this directory, or null when there is none. */
|
|
49
|
+
function liveDevServer(dir) {
|
|
50
|
+
const record = readDevServerRecord(dir);
|
|
51
|
+
return record && processAlive(record.pid) ? record : null;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { writeDevServerRecord as n, liveDevServer as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as stampWorkerHeaders, t as
|
|
1
|
+
import { n as redactCanopyCredentials, r as stampWorkerHeaders, t as CALLER_HEADER } from "./src-BkACMkol.mjs";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
//#region src/dev-worker.ts
|
|
@@ -12,6 +12,26 @@ function isWorkerPath(pathname) {
|
|
|
12
12
|
}
|
|
13
13
|
/** The manual schedule tick, mirroring the shim's own path (docs/07). */
|
|
14
14
|
const SCHEDULED_PATH = "/__canopy/scheduled";
|
|
15
|
+
/** A project's tools endpoint (docs/49), served for `canopy mcp serve --local`. */
|
|
16
|
+
const MCP_PATH = "/__canopy/mcp";
|
|
17
|
+
const LOCAL_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
18
|
+
"localhost",
|
|
19
|
+
"127.0.0.1",
|
|
20
|
+
"[::1]"
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Whether a request to the local tools endpoint comes from `canopy mcp serve
|
|
24
|
+
* --local` rather than a web page. A page on any site can make the browser
|
|
25
|
+
* call a localhost port (and DNS rebinding can make it look same-origin), so
|
|
26
|
+
* the dev server answers only a localhost Host with no Origin header at all —
|
|
27
|
+
* the CLI bridge sends none, a browser always does on a cross-site POST.
|
|
28
|
+
*/
|
|
29
|
+
function isLocalToolsRequest(headers) {
|
|
30
|
+
if (headers.has("origin")) return false;
|
|
31
|
+
const host = (headers.get("host") ?? "").toLowerCase();
|
|
32
|
+
const hostname = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0];
|
|
33
|
+
return LOCAL_HOSTNAMES.has(hostname ?? "");
|
|
34
|
+
}
|
|
15
35
|
/**
|
|
16
36
|
* `.dev.vars` — local-only worker secrets, the wrangler-shaped file the repo
|
|
17
37
|
* already gitignores.
|
|
@@ -255,11 +275,23 @@ function workerDevPlugin(options) {
|
|
|
255
275
|
server.middlewares.use((req, res, next) => {
|
|
256
276
|
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
257
277
|
const scheduled = path === SCHEDULED_PATH;
|
|
258
|
-
|
|
278
|
+
const tools = path === MCP_PATH;
|
|
279
|
+
if (!isWorkerPath(path) && !scheduled && !tools) return next();
|
|
259
280
|
(async () => {
|
|
260
281
|
const address = server.httpServer?.address();
|
|
261
282
|
if (apiBase === "" && address != null && typeof address === "object") apiBase = `http://${loopbackHost(address.address)}:${address.port}/_api`;
|
|
262
283
|
const request = await toRequest(req, `http://${req.headers.host ?? "localhost"}`);
|
|
284
|
+
request.headers.delete(CALLER_HEADER);
|
|
285
|
+
if (tools) {
|
|
286
|
+
if (!isLocalToolsRequest(request.headers)) {
|
|
287
|
+
await sendResponse(res, Response.json({
|
|
288
|
+
error: "local_tools_forbidden",
|
|
289
|
+
detail: "the local tools endpoint only answers `canopy mcp serve --local`"
|
|
290
|
+
}, { status: 403 }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
request.headers.set(CALLER_HEADER, "agent");
|
|
294
|
+
}
|
|
263
295
|
const worker = await load();
|
|
264
296
|
const env = buildWorkerEnv(options, request, {
|
|
265
297
|
canopyFetch,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Agent tools (MCP)
|
|
3
|
+
description: Let AI agents like Claude Code and Cursor use your project's backend as tools — as the person running them.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Your project's backend can offer **tools** to AI agents: Claude Code, Claude
|
|
7
|
+
Desktop, Cursor, or any client that speaks MCP (the Model Context Protocol). A
|
|
8
|
+
colleague adds one line to their agent and can then ask it to "list my open
|
|
9
|
+
tickets" or "add a note", and the agent calls your backend to do it.
|
|
10
|
+
|
|
11
|
+
The agent always acts as the signed-in Monk who runs it. It sees what they
|
|
12
|
+
could see in the browser, nothing more, and your routes get the same
|
|
13
|
+
`env.USER` as for that person's page.
|
|
14
|
+
|
|
15
|
+
Tools need a backend (`worker/` in your project). Agents reach them through
|
|
16
|
+
the `canopy` CLI on the user's own machine, so there is nothing public to
|
|
17
|
+
secure.
|
|
18
|
+
|
|
19
|
+
## Wrap the routes you already have
|
|
20
|
+
|
|
21
|
+
Most projects already have the right routes. Point a tool at one:
|
|
22
|
+
|
|
23
|
+
```ts title="worker/index.ts"
|
|
24
|
+
import { mountMcpTools } from "@mediamonks/canopy/mcp";
|
|
25
|
+
|
|
26
|
+
mountMcpTools(app, [
|
|
27
|
+
{
|
|
28
|
+
name: "list_notes",
|
|
29
|
+
description: "List the current user's notes, newest first",
|
|
30
|
+
route: "GET /api/notes",
|
|
31
|
+
annotations: { readOnlyHint: true },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "get_note",
|
|
35
|
+
description: "Read one note",
|
|
36
|
+
route: "GET /api/notes/:id",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { id: { type: "string" } },
|
|
40
|
+
required: ["id"],
|
|
41
|
+
},
|
|
42
|
+
annotations: { readOnlyHint: true },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "create_note",
|
|
46
|
+
description: "Create a note",
|
|
47
|
+
route: "POST /api/notes",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: { title: { type: "string" }, body: { type: "string" } },
|
|
51
|
+
required: ["title"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Deploy, and that's it. When an agent calls a tool, your route runs exactly as
|
|
58
|
+
if the person had clicked a button in your app:
|
|
59
|
+
|
|
60
|
+
- `:id`-style parts of the path are filled from the arguments of the same name.
|
|
61
|
+
- For `GET` and `DELETE`, the other arguments become the query string. For
|
|
62
|
+
other methods they're sent as a JSON body.
|
|
63
|
+
- A JSON response becomes the tool's result. An error response (say a 403)
|
|
64
|
+
comes back to the agent as a tool error with the status and body.
|
|
65
|
+
|
|
66
|
+
The arguments are checked against `inputSchema` before your route runs, so a
|
|
67
|
+
route never sees a call with a required field missing.
|
|
68
|
+
|
|
69
|
+
## Write a tool directly
|
|
70
|
+
|
|
71
|
+
When there's no route to wrap, give the tool a `handler`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { z } from "zod";
|
|
75
|
+
|
|
76
|
+
mountMcpTools(app, [
|
|
77
|
+
{
|
|
78
|
+
name: "summarise_week",
|
|
79
|
+
description: "Summarise this week's activity for the caller",
|
|
80
|
+
inputSchema: z.object({ team: z.string().optional() }),
|
|
81
|
+
annotations: { readOnlyHint: true },
|
|
82
|
+
handler: async ({ team }, ctx) => {
|
|
83
|
+
// ctx.user is the signed-in Monk; ctx.env is your backend's env.
|
|
84
|
+
return { team: team ?? "all", user: ctx.user?.email };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Return an object (the agent gets it as structured data), a string, or throw an
|
|
91
|
+
error to report a failure. The schema can be plain JSON Schema or anything
|
|
92
|
+
that implements Standard Schema with JSON Schema output: Zod 4, Valibot (with
|
|
93
|
+
`toStandardJsonSchema`) or ArkType.
|
|
94
|
+
|
|
95
|
+
Tools are defined in the shape the MCP specification uses (`name`, `title`,
|
|
96
|
+
`description`, `inputSchema`, `outputSchema`, `annotations`), so tools you
|
|
97
|
+
wrote for another MCP server carry over as they are.
|
|
98
|
+
|
|
99
|
+
### Vercel AI SDK tools
|
|
100
|
+
|
|
101
|
+
A tool set you already use with the AI SDK works too. Put it in the same list:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { tool } from "ai";
|
|
105
|
+
|
|
106
|
+
const aiTools = {
|
|
107
|
+
weather: tool({
|
|
108
|
+
description: "Weather in a city",
|
|
109
|
+
inputSchema: z.object({ city: z.string() }),
|
|
110
|
+
execute: async ({ city }) => ({ city, tempC: 21 }),
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
mountMcpTools(app, [...myTools, aiTools]);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Tools without `execute` (ones the AI provider runs itself) are left out.
|
|
118
|
+
|
|
119
|
+
## Write good tools
|
|
120
|
+
|
|
121
|
+
The agent reads your `description` to decide when to call a tool, and the
|
|
122
|
+
schema to decide what to send. A few habits help a lot:
|
|
123
|
+
|
|
124
|
+
- **Say what it does and when to use it.** "List the current user's notes,
|
|
125
|
+
newest first" beats "notes".
|
|
126
|
+
- **Mark read-only tools** with `annotations: { readOnlyHint: true }`. Clients
|
|
127
|
+
may skip asking before calling those. Tools that aren't marked read-only are
|
|
128
|
+
treated as possibly destructive, so the agent asks the user first.
|
|
129
|
+
- **Keep results small and to the point.** Return what the agent needs, not
|
|
130
|
+
your whole table.
|
|
131
|
+
- **Don't trust the data blindly.** Text in your database was written by
|
|
132
|
+
people, and an agent reads it as instructions it might follow. Keep tools
|
|
133
|
+
that change or delete things narrow, and leave them unmarked so the user
|
|
134
|
+
confirms each call.
|
|
135
|
+
|
|
136
|
+
## Connect your agent
|
|
137
|
+
|
|
138
|
+
Anyone who can open the project in a browser can connect. For
|
|
139
|
+
collaborators-only projects, that means collaborators only. From a terminal:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
canopy mcp add <slug>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
It checks you can use the project, says how many tools it has, and prints the
|
|
146
|
+
line for your client:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
claude mcp add canopy-notes -- canopy mcp serve notes --profile main
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
For Claude Desktop, Cursor and other clients it also prints an `mcpServers`
|
|
153
|
+
JSON block. None of it contains a password or token: `canopy mcp serve` uses
|
|
154
|
+
your `canopy login` and keeps its short-lived credentials in memory.
|
|
155
|
+
|
|
156
|
+
When you redeploy with new or changed tools, a running agent session is told
|
|
157
|
+
the list changed, and new sessions always see the current tools. You don't
|
|
158
|
+
need to run anything again.
|
|
159
|
+
|
|
160
|
+
## Develop tools locally
|
|
161
|
+
|
|
162
|
+
Run `canopy dev` in your project, then point your agent at your working tree:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
claude mcp add notes-local -- canopy mcp serve --local
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The agent calls the tools in your local backend as you. Edit a tool, save, and
|
|
169
|
+
the next listing shows the change.
|
|
170
|
+
|
|
171
|
+
## Use your own MCP server
|
|
172
|
+
|
|
173
|
+
`mountMcpTools` is built on the official MCP SDK
|
|
174
|
+
(`@modelcontextprotocol/server`). If you'd rather use it directly, or another
|
|
175
|
+
MCP library such as Vercel's `mcp-handler`, mount it at `MCP_PATH`:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
|
|
179
|
+
import { MCP_PATH, mcpCaller } from "@mediamonks/canopy/mcp";
|
|
180
|
+
|
|
181
|
+
const mcp = createMcpHandler(() => {
|
|
182
|
+
const server = new McpServer({ name: "notes", version: "1.0.0" });
|
|
183
|
+
// register your tools here
|
|
184
|
+
return server;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
app.all(MCP_PATH, (c) => mcp.fetch(c.req.raw));
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`mcpCaller(request, env)` tells you who is calling (`user`) and whether it's
|
|
191
|
+
an agent (`agent`), in any route. Two limits apply to your own server:
|
|
192
|
+
|
|
193
|
+
- It must answer on that single URL, using MCP's streamable HTTP transport.
|
|
194
|
+
The older SSE transport, which needs a second URL, doesn't work.
|
|
195
|
+
- Agents only ever see its tools. Prompts, resources, and requests from your
|
|
196
|
+
server to the agent (sampling, roots, elicitation) are not passed on.
|
|
197
|
+
|
|
198
|
+
## What agents can and can't do
|
|
199
|
+
|
|
200
|
+
- They act as the person running them, with the same access that person has
|
|
201
|
+
in the browser. AI spend counts against your project's budget as usual.
|
|
202
|
+
- They can only call your tools. They can't reach your app's pages or `/api/*`
|
|
203
|
+
routes directly, the dashboard, or other projects.
|
|
204
|
+
- Their access ends when the person signs out (`canopy logout`, or "sign out
|
|
205
|
+
everywhere"), or loses access to a collaborators-only project.
|
|
206
|
+
- Very long tool descriptions or schemas are held back, a project shows at
|
|
207
|
+
most 64 tools, results over 512 KB become an error, and a call stops after
|
|
208
|
+
60 seconds.
|
|
209
|
+
- Agent calls show up in your backend's logs like any other request.
|
|
210
|
+
|
|
211
|
+
See the [SDK reference](https://docs.canopy.website/sdk/mcp) for every option and [canopy mcp](https://docs.canopy.website/cli/mcp)
|
|
212
|
+
for every flag.
|