@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.
Files changed (40) hide show
  1. package/dist/{ai-fAvjdjVX.mjs → ai-DHLnyqfq.mjs} +2 -1
  2. package/dist/{ui-CeupNHNa.mjs → api-C4rMhE7i.mjs} +1 -122
  3. package/dist/{db-KTLn8J1u.mjs → db-BB6O6085.mjs} +3 -2
  4. package/dist/{deploy-CBf44bvG.mjs → deploy-Ka2lHkyy.mjs} +114 -2
  5. package/dist/{dev-JlzMZJow.mjs → dev-CkhBnSlv.mjs} +13 -6
  6. package/dist/dev-MoHHjQh1.mjs +2 -0
  7. package/dist/dev-server-file-Dk9pk4AR.mjs +54 -0
  8. package/dist/{dev-worker-DjJGSUGj.mjs → dev-worker-CaCsGBkn.mjs} +34 -2
  9. package/dist/docs/agent-tools.md +212 -0
  10. package/dist/docs/cli-mcp.md +88 -0
  11. package/dist/docs/developers-agents-and-ai.md +8 -0
  12. package/dist/docs/index.json +15 -0
  13. package/dist/docs/sdk-mcp.md +222 -0
  14. package/dist/{docs-Dg0UJugG.mjs → docs-CtNH5kbN.mjs} +2 -2
  15. package/dist/{egress-Cn9zDfDT.mjs → egress-If_Gsx_M.mjs} +2 -1
  16. package/dist/{feedback-Dvb6CdlP.mjs → feedback-_L3vqktY.mjs} +3 -2
  17. package/dist/index.mjs +32 -29
  18. package/dist/{local-BA_TPWpN.mjs → local-DAYCNUyz.mjs} +3 -3
  19. package/dist/{login-MX0xq8UE.mjs → login-B-m-i8uu.mjs} +1 -1
  20. package/dist/{logs-D5RWqsWl.mjs → logs-DQQOawOG.mjs} +2 -1
  21. package/dist/mcp-RcN17Z2v.mjs +457 -0
  22. package/dist/{migrations-C8yRPJIX.mjs → migrations-Czlcddyz.mjs} +1 -1
  23. package/dist/{open-BJgd0R61.mjs → open-DcaQKi1v.mjs} +1 -1
  24. package/dist/{package-CnS450Yc.mjs → package-YGjjin4p.mjs} +1 -1
  25. package/dist/{projects-BG7Injpb.mjs → projects-CN2YhCZn.mjs} +2 -1
  26. package/dist/{schedule-CLuksxSM.mjs → schedule-C5lMf3ny.mjs} +2 -1
  27. package/dist/{secrets-Bl3uOx4l.mjs → secrets-BSEjZnxg.mjs} +2 -1
  28. package/dist/skill/canopy/SKILL.md +7 -0
  29. package/dist/{skill-CuES7kPj.mjs → skill-CM7R-4Bq.mjs} +2 -2
  30. package/dist/{src-C7zPhbxi.mjs → src-BkACMkol.mjs} +15 -1
  31. package/dist/{staging-D06zobnf.mjs → staging-BZVty57b.mjs} +2 -1
  32. package/dist/templates/spa/package.json +1 -1
  33. package/dist/templates/worker/package.json +1 -1
  34. package/dist/templates/worker/worker/index.ts +17 -0
  35. package/dist/{token-C_PFNqcv.mjs → token-HHlUR0CF.mjs} +2 -1
  36. package/dist/ui-C_jSc1Kz.mjs +123 -0
  37. package/dist/{upgrade-C8YdSKKd.mjs → upgrade-ChcSZW8i.mjs} +2 -2
  38. package/dist/{usage-CV7TjTO8.mjs → usage-CUYrWxv0.mjs} +2 -1
  39. package/package.json +5 -2
  40. package/dist/dev-DGCu9MjB.mjs +0 -2
@@ -1,4 +1,5 @@
1
- import { d as withContext, l as apiRequest, r as reportErrors } from "./ui-CeupNHNa.mjs";
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
- //#region src/ui.ts
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 { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, s as apiDataRequest } from "./ui-CeupNHNa.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C8yRPJIX.mjs";
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 { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui-CeupNHNa.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C8yRPJIX.mjs";
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 { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui-CeupNHNa.mjs";
3
- import { r as readMigrationFiles } from "./migrations-C8yRPJIX.mjs";
4
- import { n as stampWorkerHeaders } from "./src-C7zPhbxi.mjs";
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-DjJGSUGj.mjs");
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-BA_TPWpN.mjs");
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-BA_TPWpN.mjs");
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,2 @@
1
+ import { n as devCommand } from "./dev-CkhBnSlv.mjs";
2
+ export { devCommand };
@@ -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 redactCanopyCredentials } from "./src-C7zPhbxi.mjs";
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
- if (!isWorkerPath(path) && !scheduled) return next();
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.