@mediamonks/canopy-cli 0.7.30 → 0.7.31
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-B-znPE-v.mjs} +4 -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 +213 -0
- package/dist/docs/cli-mcp.md +88 -0
- package/dist/docs/developers-agents-and-ai.md +8 -0
- package/dist/docs/index.json +10 -0
- package/dist/{docs-Dg0UJugG.mjs → docs-YD4z70gq.mjs} +2 -2
- package/dist/{egress-Cn9zDfDT.mjs → egress-If_Gsx_M.mjs} +2 -1
- package/dist/{feedback-Dvb6CdlP.mjs → feedback-Cd7Rvg9a.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-GKwctfHN.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-CiKTWo0_.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-BWmXiJAz.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-Cexs9KYX.mjs} +2 -2
- package/dist/{usage-CV7TjTO8.mjs → usage-CUYrWxv0.mjs} +2 -1
- package/package.json +4 -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,7 @@
|
|
|
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";
|
|
4
5
|
import { defineCommand } from "citty";
|
|
5
6
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
@@ -26,6 +27,7 @@ async function bundleWorker(projectDir, entry) {
|
|
|
26
27
|
format: "esm",
|
|
27
28
|
target: "es2022",
|
|
28
29
|
packages: "bundle",
|
|
30
|
+
external: ["node:*"],
|
|
29
31
|
logLevel: "silent"
|
|
30
32
|
});
|
|
31
33
|
return new Uint8Array(readFileSync(outfile));
|
|
@@ -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,213 @@
|
|
|
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-sdk/provider-utils";
|
|
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
|
+
Import `tool` from `@ai-sdk/provider-utils` in a backend: the full `ai`
|
|
118
|
+
package doesn't bundle for Canopy backends yet. Tools without `execute` (ones
|
|
119
|
+
the AI provider runs itself) are left out.
|
|
120
|
+
|
|
121
|
+
## Write good tools
|
|
122
|
+
|
|
123
|
+
The agent reads your `description` to decide when to call a tool, and the
|
|
124
|
+
schema to decide what to send. A few habits help a lot:
|
|
125
|
+
|
|
126
|
+
- **Say what it does and when to use it.** "List the current user's notes,
|
|
127
|
+
newest first" beats "notes".
|
|
128
|
+
- **Mark read-only tools** with `annotations: { readOnlyHint: true }`. Clients
|
|
129
|
+
may skip asking before calling those. Tools that aren't marked read-only are
|
|
130
|
+
treated as possibly destructive, so the agent asks the user first.
|
|
131
|
+
- **Keep results small and to the point.** Return what the agent needs, not
|
|
132
|
+
your whole table.
|
|
133
|
+
- **Don't trust the data blindly.** Text in your database was written by
|
|
134
|
+
people, and an agent reads it as instructions it might follow. Keep tools
|
|
135
|
+
that change or delete things narrow, and leave them unmarked so the user
|
|
136
|
+
confirms each call.
|
|
137
|
+
|
|
138
|
+
## Connect your agent
|
|
139
|
+
|
|
140
|
+
Anyone who can open the project in a browser can connect. For
|
|
141
|
+
collaborators-only projects, that means collaborators only. From a terminal:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
canopy mcp add <slug>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It checks you can use the project, says how many tools it has, and prints the
|
|
148
|
+
line for your client:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
claude mcp add canopy-notes -- canopy mcp serve notes --profile main
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For Claude Desktop, Cursor and other clients it also prints an `mcpServers`
|
|
155
|
+
JSON block. None of it contains a password or token: `canopy mcp serve` uses
|
|
156
|
+
your `canopy login` and keeps its short-lived credentials in memory.
|
|
157
|
+
|
|
158
|
+
When you redeploy with new or changed tools, a running agent session is told
|
|
159
|
+
the list changed, and new sessions always see the current tools. You don't
|
|
160
|
+
need to run anything again.
|
|
161
|
+
|
|
162
|
+
## Develop tools locally
|
|
163
|
+
|
|
164
|
+
Run `canopy dev` in your project, then point your agent at your working tree:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
claude mcp add notes-local -- canopy mcp serve --local
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The agent calls the tools in your local backend as you. Edit a tool, save, and
|
|
171
|
+
the next listing shows the change.
|
|
172
|
+
|
|
173
|
+
## Use your own MCP server
|
|
174
|
+
|
|
175
|
+
`mountMcpTools` is built on the official MCP SDK
|
|
176
|
+
(`@modelcontextprotocol/server`). If you'd rather use it directly, or another
|
|
177
|
+
MCP library such as Vercel's `mcp-handler`, mount it at `MCP_PATH`:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
|
|
181
|
+
import { MCP_PATH, mcpCaller } from "@mediamonks/canopy/mcp";
|
|
182
|
+
|
|
183
|
+
const mcp = createMcpHandler(() => {
|
|
184
|
+
const server = new McpServer({ name: "notes", version: "1.0.0" });
|
|
185
|
+
// register your tools here
|
|
186
|
+
return server;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.all(MCP_PATH, (c) => mcp.fetch(c.req.raw));
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`mcpCaller(request, env)` tells you who is calling (`user`) and whether it's
|
|
193
|
+
an agent (`agent`), in any route. Two limits apply to your own server:
|
|
194
|
+
|
|
195
|
+
- It must answer on that single URL, using MCP's streamable HTTP transport.
|
|
196
|
+
The older SSE transport, which needs a second URL, doesn't work.
|
|
197
|
+
- Agents only ever see its tools. Prompts, resources, and requests from your
|
|
198
|
+
server to the agent (sampling, roots, elicitation) are not passed on.
|
|
199
|
+
|
|
200
|
+
## What agents can and can't do
|
|
201
|
+
|
|
202
|
+
- They act as the person running them, with the same access that person has
|
|
203
|
+
in the browser. AI spend counts against your project's budget as usual.
|
|
204
|
+
- They can only call your tools. They can't reach your app's pages or `/api/*`
|
|
205
|
+
routes directly, the dashboard, or other projects.
|
|
206
|
+
- Their access ends when the person signs out (`canopy logout`, or "sign out
|
|
207
|
+
everywhere"), or loses access to a collaborators-only project.
|
|
208
|
+
- Very long tool descriptions or schemas are held back, a project shows at
|
|
209
|
+
most 64 tools, results over 512 KB become an error, and a call stops after
|
|
210
|
+
60 seconds.
|
|
211
|
+
- Agent calls show up in your backend's logs like any other request.
|
|
212
|
+
|
|
213
|
+
See [canopy mcp](https://docs.canopy.website/cli/mcp) for every flag.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: canopy mcp
|
|
3
|
+
description: Let AI agents use a project's backend as MCP tools — check access, print client config, and run the stdio server.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`canopy mcp` connects AI agents (Claude Code, Claude Desktop, Cursor, any MCP
|
|
7
|
+
client) to a project's tools. The agent always acts as you. See
|
|
8
|
+
[Agent tools](https://docs.canopy.website/guides/agent-tools) for how a project offers tools.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
canopy mcp add [slug] [--channel staging]
|
|
14
|
+
canopy mcp serve [slug] [--channel staging] [--local]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## canopy mcp add
|
|
18
|
+
|
|
19
|
+
Checks that you're signed in and may open the project, reports how many tools
|
|
20
|
+
it serves, and prints configuration for your client: a `claude mcp add` line
|
|
21
|
+
for Claude Code, and an `mcpServers` JSON block for Claude Desktop, Cursor and
|
|
22
|
+
others. It writes nothing; copy what you need.
|
|
23
|
+
|
|
24
|
+
The printed command names the site (`--profile`) and, for staging, the
|
|
25
|
+
channel, so it keeps working whatever your default site is. It contains no
|
|
26
|
+
credentials.
|
|
27
|
+
|
|
28
|
+
## canopy mcp serve
|
|
29
|
+
|
|
30
|
+
The MCP server your client starts. It talks MCP on stdin/stdout and writes
|
|
31
|
+
nothing else there, so everything it has to say goes to stderr, where clients
|
|
32
|
+
show it as the server's log.
|
|
33
|
+
|
|
34
|
+
It uses your `canopy login`: it exchanges it for short-lived credentials for
|
|
35
|
+
this one project, keeps them in memory, and renews them as needed. If you're
|
|
36
|
+
not signed in, it exits with a message telling you to run `canopy login`.
|
|
37
|
+
|
|
38
|
+
Only the project's tools reach the agent, and the tool list stays current: a
|
|
39
|
+
redeploy with new tools reaches a running session without restarting
|
|
40
|
+
anything.
|
|
41
|
+
|
|
42
|
+
## Arguments & flags
|
|
43
|
+
|
|
44
|
+
- `slug` — the project. Defaults to the `canopy.json` in the current directory.
|
|
45
|
+
- `--channel <live|staging>` — which channel's backend to use. Default `live`.
|
|
46
|
+
- `--local` (serve only) — use the tools of the `canopy dev` running in the
|
|
47
|
+
current directory instead of the deployed project.
|
|
48
|
+
- `--profile <id>` — which Canopy site, as for every command.
|
|
49
|
+
|
|
50
|
+
## Examples
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
canopy mcp add notes
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Check you can use `notes` and print its client configuration.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
claude mcp add canopy-notes -- canopy mcp serve notes --profile main
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Add `notes` to Claude Code (the line `canopy mcp add` printed).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude mcp add notes-staging -- canopy mcp serve notes --channel staging --profile main
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Try the staging channel's tools before promoting them.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
claude mcp add notes-local -- canopy mcp serve --local
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
While developing: the tools from your working tree, served by `canopy dev`.
|
|
75
|
+
|
|
76
|
+
## When it can't connect
|
|
77
|
+
|
|
78
|
+
`canopy mcp add` and `canopy mcp serve` say what's wrong in plain words. The
|
|
79
|
+
usual cases:
|
|
80
|
+
|
|
81
|
+
- **Not signed in** — run `canopy login`.
|
|
82
|
+
- **Limited to its collaborators** — ask the owner to add you.
|
|
83
|
+
- **No backend, or serves no tools** — the project has no `worker/`, or it
|
|
84
|
+
doesn't mount any tools yet.
|
|
85
|
+
- **Redeploy required** — the backend was deployed before tools existed. Run
|
|
86
|
+
`canopy deploy`.
|
|
87
|
+
- **Invalid tool definitions** — two tools share a name, or a schema is wrong.
|
|
88
|
+
The message names the tool.
|
|
@@ -64,6 +64,14 @@ already knows the rules:
|
|
|
64
64
|
use `canopy dev --local` for an offline sandbox where writes never reach
|
|
65
65
|
the platform.
|
|
66
66
|
|
|
67
|
+
## Give agents your project's tools
|
|
68
|
+
|
|
69
|
+
The other way round: your project can offer its backend to agents as MCP
|
|
70
|
+
tools, so a colleague's Claude Code or Cursor can call it (as them) without
|
|
71
|
+
opening the app. Wrap the routes you already have with `mountMcpTools`, deploy,
|
|
72
|
+
and people connect with `canopy mcp add <slug>`. See
|
|
73
|
+
[Agent tools](https://docs.canopy.website/guides/agent-tools).
|
|
74
|
+
|
|
67
75
|
## Prompts that work
|
|
68
76
|
|
|
69
77
|
With the skill installed, these are safe, copy-pasteable asks — each maps to
|
package/dist/docs/index.json
CHANGED
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
"title": "CI and deploy tokens",
|
|
68
68
|
"summary": "Push-to-main deploys using a project-scoped deploy token."
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"slug": "agent-tools",
|
|
72
|
+
"title": "Agent tools (MCP)",
|
|
73
|
+
"summary": "Let AI agents like Claude Code and Cursor use your project's backend as tools — as the person running them."
|
|
74
|
+
},
|
|
70
75
|
{
|
|
71
76
|
"slug": "porting-nextjs",
|
|
72
77
|
"title": "Porting a Next.js app",
|
|
@@ -286,6 +291,11 @@
|
|
|
286
291
|
"title": "canopy feedback",
|
|
287
292
|
"summary": "Read and triage a project's feedback inbox, or file a bug/feedback report — against a project or against Canopy itself."
|
|
288
293
|
},
|
|
294
|
+
{
|
|
295
|
+
"slug": "cli-mcp",
|
|
296
|
+
"title": "canopy mcp",
|
|
297
|
+
"summary": "Let AI agents use a project's backend as MCP tools — check access, print client config, and run the stdio server."
|
|
298
|
+
},
|
|
289
299
|
{
|
|
290
300
|
"slug": "cli-open",
|
|
291
301
|
"title": "canopy open",
|