@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
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { t as version } from "./package-YGjjin4p.mjs";
|
|
2
|
+
import { d as tryResolveProject, l as stagingUrl, n as siteConfig, s as projectUrl } from "./site-DM2J10CB.mjs";
|
|
3
|
+
import { i as apiRequest, t as ApiError } from "./api-C4rMhE7i.mjs";
|
|
4
|
+
import { t as liveDevServer } from "./dev-server-file-Dk9pk4AR.mjs";
|
|
5
|
+
import { defineCommand } from "citty";
|
|
6
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
7
|
+
import { Server } from "@modelcontextprotocol/server";
|
|
8
|
+
import { Client, StreamableHTTPClientTransport, isInputRequiredResult } from "@modelcontextprotocol/client";
|
|
9
|
+
//#region src/mcp-bridge.ts
|
|
10
|
+
/**
|
|
11
|
+
* The agent side of `canopy mcp serve` (docs/49): an MCP server on stdio that
|
|
12
|
+
* re-exposes a project's tools, and nothing else. Not a byte pipe — each
|
|
13
|
+
* side negotiates its own protocol revision, only `tools/list` and
|
|
14
|
+
* `tools/call` cross, and both are bounded:
|
|
15
|
+
*
|
|
16
|
+
* - descriptions over 2,048 characters or input schemas over 16 KB are
|
|
17
|
+
* withheld, and at most 64 tools are shown;
|
|
18
|
+
* - a result over 512 KB becomes an error, and a call is cut off at 60 s.
|
|
19
|
+
*
|
|
20
|
+
* The tool list is live: fetched whenever the agent lists tools and again
|
|
21
|
+
* after each call, with a list-changed notification when it moved, so a
|
|
22
|
+
* redeploy reaches a running session without any Canopy command.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_DESCRIPTION_CHARS = 2048;
|
|
25
|
+
const MAX_SCHEMA_BYTES = 16 * 1024;
|
|
26
|
+
const MAX_RESULT_BYTES = 512 * 1024;
|
|
27
|
+
const byteLength = (value) => new TextEncoder().encode(JSON.stringify(value) ?? "").length;
|
|
28
|
+
/** Apply the caps; returns the tools to show and why the rest were held back. */
|
|
29
|
+
function boundTools(tools) {
|
|
30
|
+
const shown = [];
|
|
31
|
+
const withheld = [];
|
|
32
|
+
for (const tool of tools) if ((tool.description?.length ?? 0) > 2048) withheld.push(`${tool.name}: description over ${MAX_DESCRIPTION_CHARS} characters`);
|
|
33
|
+
else if (byteLength(tool.inputSchema) > 16384) withheld.push(`${tool.name}: input schema over ${MAX_SCHEMA_BYTES / 1024} KB`);
|
|
34
|
+
else if (shown.length >= 64) withheld.push(`${tool.name}: over the limit of 64 tools`);
|
|
35
|
+
else shown.push(tool);
|
|
36
|
+
return {
|
|
37
|
+
shown,
|
|
38
|
+
withheld
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function toolError(text) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{
|
|
44
|
+
type: "text",
|
|
45
|
+
text
|
|
46
|
+
}],
|
|
47
|
+
isError: true
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A stable fingerprint of what the agent was shown, to spot a changed list. */
|
|
51
|
+
const fingerprint = (tools) => JSON.stringify(tools.map((t) => [t.name, t]));
|
|
52
|
+
function createBridgeServer(upstream, options = {}) {
|
|
53
|
+
const log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
54
|
+
const timeoutMs = options.callTimeoutMs ?? 6e4;
|
|
55
|
+
let shownPrint = null;
|
|
56
|
+
let shownNames = /* @__PURE__ */ new Set();
|
|
57
|
+
let reported = /* @__PURE__ */ new Set();
|
|
58
|
+
const server = new Server({
|
|
59
|
+
name: "canopy",
|
|
60
|
+
version: options.version ?? "1"
|
|
61
|
+
}, { capabilities: { tools: { listChanged: true } } });
|
|
62
|
+
async function currentTools() {
|
|
63
|
+
const { shown, withheld } = boundTools(await upstream.listTools());
|
|
64
|
+
const now = new Set(withheld);
|
|
65
|
+
for (const line of withheld) if (!reported.has(line)) log(`canopy mcp: withheld ${line}`);
|
|
66
|
+
reported = now;
|
|
67
|
+
return shown;
|
|
68
|
+
}
|
|
69
|
+
/** Re-read the project's list; tell the agent when it moved. */
|
|
70
|
+
async function refresh() {
|
|
71
|
+
try {
|
|
72
|
+
const tools = await currentTools();
|
|
73
|
+
const print = fingerprint(tools);
|
|
74
|
+
if (shownPrint !== null && print !== shownPrint) {
|
|
75
|
+
shownPrint = print;
|
|
76
|
+
shownNames = new Set(tools.map((t) => t.name));
|
|
77
|
+
await server.sendToolListChanged().catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
server.setRequestHandler("tools/list", async () => {
|
|
82
|
+
const tools = await currentTools();
|
|
83
|
+
shownPrint = fingerprint(tools);
|
|
84
|
+
shownNames = new Set(tools.map((t) => t.name));
|
|
85
|
+
return { tools };
|
|
86
|
+
});
|
|
87
|
+
server.setRequestHandler("tools/call", async (request) => {
|
|
88
|
+
const { name, arguments: args } = request.params;
|
|
89
|
+
if (!shownNames.has(name)) {
|
|
90
|
+
const tools = await currentTools().catch(() => []);
|
|
91
|
+
shownNames = new Set(tools.map((t) => t.name));
|
|
92
|
+
if (!shownNames.has(name)) return toolError(`The tool "${name}" is no longer offered by this project.`);
|
|
93
|
+
}
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
96
|
+
let result;
|
|
97
|
+
try {
|
|
98
|
+
result = await Promise.race([upstream.callTool(name, args, controller.signal), new Promise((_, reject) => controller.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error(`timed out after ${Math.round(timeoutMs / 1e3)} seconds`))))]);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const tools = await currentTools().catch(() => null);
|
|
101
|
+
if (tools && !tools.some((t) => t.name === name)) {
|
|
102
|
+
refresh();
|
|
103
|
+
return toolError(`The tool "${name}" is no longer offered by this project.`);
|
|
104
|
+
}
|
|
105
|
+
return toolError(`The tool "${name}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
refresh();
|
|
110
|
+
if (byteLength(result) > 524288) return toolError(`The tool "${name}" returned more than ${MAX_RESULT_BYTES / 1024} KB, which Canopy does not pass to agents.`);
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
return server;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/mcp-token.ts
|
|
117
|
+
/** Renew when less than this is left, so no call goes out on a dying token. */
|
|
118
|
+
const RENEW_BEFORE_MS = 300 * 1e3;
|
|
119
|
+
var AgentTokenSource = class {
|
|
120
|
+
mint;
|
|
121
|
+
now;
|
|
122
|
+
current = null;
|
|
123
|
+
inflight = null;
|
|
124
|
+
constructor(mint, now = Date.now) {
|
|
125
|
+
this.mint = mint;
|
|
126
|
+
this.now = now;
|
|
127
|
+
}
|
|
128
|
+
/** A token with at least RENEW_BEFORE_MS left, minting one when needed. */
|
|
129
|
+
async token() {
|
|
130
|
+
if (this.current && this.current.expiresAt - this.now() > 3e5) return this.current.token;
|
|
131
|
+
this.inflight ??= this.mint().then((minted) => {
|
|
132
|
+
const expiresAt = Date.parse(minted.expires_at);
|
|
133
|
+
this.current = {
|
|
134
|
+
token: minted.token,
|
|
135
|
+
expiresAt: Number.isNaN(expiresAt) ? this.now() + RENEW_BEFORE_MS * 2 : expiresAt
|
|
136
|
+
};
|
|
137
|
+
return minted.token;
|
|
138
|
+
}).finally(() => {
|
|
139
|
+
this.inflight = null;
|
|
140
|
+
});
|
|
141
|
+
return this.inflight;
|
|
142
|
+
}
|
|
143
|
+
/** Forget the current token (the gateway said it is no longer valid). */
|
|
144
|
+
invalidate(token) {
|
|
145
|
+
if (!token || this.current?.token === token) this.current = null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/mcp-upstream.ts
|
|
150
|
+
/**
|
|
151
|
+
* The project side of `canopy mcp serve` (docs/49): an MCP client over HTTP to
|
|
152
|
+
* the project's `/__canopy/mcp`, authenticated with an agent token. It
|
|
153
|
+
* declares no client capabilities (no roots, sampling or elicitation), so a
|
|
154
|
+
* project can never ask the user's agent for anything — requests it sends
|
|
155
|
+
* anyway are answered method-not-found by the client and never forwarded.
|
|
156
|
+
*/
|
|
157
|
+
/** A refusal the platform or the project answered with, in words for stderr. */
|
|
158
|
+
var UpstreamRefusal = class extends Error {
|
|
159
|
+
status;
|
|
160
|
+
code;
|
|
161
|
+
name = "UpstreamRefusal";
|
|
162
|
+
constructor(status, code, message) {
|
|
163
|
+
super(message);
|
|
164
|
+
this.status = status;
|
|
165
|
+
this.code = code;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/** What each refusal means to the person who started the agent. */
|
|
169
|
+
function explainRefusal(status, body, project) {
|
|
170
|
+
switch (body.error) {
|
|
171
|
+
case "agent_token_required":
|
|
172
|
+
case "agent_token_invalid": return "Canopy refused the sign-in for this agent session — run `canopy login` and restart the MCP server.";
|
|
173
|
+
case "agent_token_scope": return `The agent credential does not cover ${project} on this channel.`;
|
|
174
|
+
case "members_only": return `${project} is limited to its collaborators — ask the owner to add you.`;
|
|
175
|
+
case "project_not_found": return `There is no project ${project} on this site — check the slug and --profile.`;
|
|
176
|
+
case "project_paused": return `${project} is paused.`;
|
|
177
|
+
case "project_moved": return `${project} has moved${body.moved_to ? ` to ${body.moved_to}` : ""}.`;
|
|
178
|
+
case "no_backend": return `${project} has no backend on this channel, so it has no tools.`;
|
|
179
|
+
case "redeploy_required": return `${project}'s backend was deployed before tools support — redeploy it (\`canopy deploy\`).`;
|
|
180
|
+
case "invalid_tools": return `${project}'s tool definitions are invalid: ${body.detail ?? "see the project's logs"}`;
|
|
181
|
+
case "cli_session_required": return "Agent sessions need your own CLI sign-in (not a deploy token) — run `canopy login`.";
|
|
182
|
+
case "local_tools_forbidden": return "The local dev server refused the tools request.";
|
|
183
|
+
default:
|
|
184
|
+
if (status === 404 || status === 405) return `${project} serves no tools — mount them with mountMcpTools (@mediamonks/canopy/mcp) and redeploy.`;
|
|
185
|
+
return `${project}'s tools endpoint answered HTTP ${status}${body.error ? ` (${body.error})` : ""}.`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function connectUpstream(options) {
|
|
189
|
+
const baseFetch = options.fetch ?? fetch;
|
|
190
|
+
let refusal = null;
|
|
191
|
+
const authedFetch = async (url, init) => {
|
|
192
|
+
const send = async (token) => {
|
|
193
|
+
const headers = new Headers(init?.headers);
|
|
194
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
195
|
+
return baseFetch(url, {
|
|
196
|
+
...init,
|
|
197
|
+
headers
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
let token = await options.tokens?.token();
|
|
201
|
+
let res = await send(token);
|
|
202
|
+
if (res.status === 401 && options.tokens) {
|
|
203
|
+
options.tokens.invalidate(token);
|
|
204
|
+
token = await options.tokens.token();
|
|
205
|
+
res = await send(token);
|
|
206
|
+
}
|
|
207
|
+
if (res.ok || res.status === 202) {
|
|
208
|
+
refusal = null;
|
|
209
|
+
return res;
|
|
210
|
+
}
|
|
211
|
+
const body = await res.clone().json().catch(() => ({}));
|
|
212
|
+
refusal = new UpstreamRefusal(res.status, body.error ?? `http_${res.status}`, explainRefusal(res.status, body, options.project));
|
|
213
|
+
return res;
|
|
214
|
+
};
|
|
215
|
+
const client = new Client({
|
|
216
|
+
name: "canopy-cli",
|
|
217
|
+
version: "1"
|
|
218
|
+
}, {
|
|
219
|
+
capabilities: {},
|
|
220
|
+
versionNegotiation: { mode: "auto" }
|
|
221
|
+
});
|
|
222
|
+
const transport = new StreamableHTTPClientTransport(new URL(options.url), { fetch: authedFetch });
|
|
223
|
+
const explain = (err) => refusal ?? (err instanceof Error ? err : new Error(String(err)));
|
|
224
|
+
try {
|
|
225
|
+
await client.connect(transport);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
throw explain(err);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
async listTools() {
|
|
231
|
+
try {
|
|
232
|
+
const tools = [];
|
|
233
|
+
let cursor;
|
|
234
|
+
do {
|
|
235
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
236
|
+
tools.push(...page.tools);
|
|
237
|
+
cursor = page.nextCursor;
|
|
238
|
+
} while (cursor);
|
|
239
|
+
return tools;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
throw explain(err);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
async callTool(name, args, signal) {
|
|
245
|
+
try {
|
|
246
|
+
const result = await client.callTool({
|
|
247
|
+
name,
|
|
248
|
+
arguments: args ?? {}
|
|
249
|
+
}, { signal });
|
|
250
|
+
if (isInputRequiredResult(result)) return {
|
|
251
|
+
content: [{
|
|
252
|
+
type: "text",
|
|
253
|
+
text: "This tool asked for interactive input, which Canopy does not pass to agents."
|
|
254
|
+
}],
|
|
255
|
+
isError: true
|
|
256
|
+
};
|
|
257
|
+
return result;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
throw explain(err);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
close: () => client.close()
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/mcp.ts
|
|
267
|
+
/**
|
|
268
|
+
* `canopy mcp` — let AI agents use a project's backend as MCP tools
|
|
269
|
+
* (docs/49).
|
|
270
|
+
*
|
|
271
|
+
* canopy mcp add [slug] check access, print the config line for your client
|
|
272
|
+
* canopy mcp serve [slug] the stdio MCP server an agent client launches
|
|
273
|
+
*
|
|
274
|
+
* The agent always acts as the signed-in Monk running it: `serve` exchanges
|
|
275
|
+
* the stored CLI sign-in for short-lived agent tokens (one project and
|
|
276
|
+
* channel, an hour each), holds them in memory only, and re-exposes the
|
|
277
|
+
* project's tools and nothing else (mcp-bridge.ts).
|
|
278
|
+
*/
|
|
279
|
+
/** The reserved backend path a project's tools live on (MCP_PATH in the SDK). */
|
|
280
|
+
const MCP_PATH = "/__canopy/mcp";
|
|
281
|
+
const sharedArgs = {
|
|
282
|
+
slug: {
|
|
283
|
+
type: "positional",
|
|
284
|
+
description: "Project slug (defaults to canopy.json in the current directory)",
|
|
285
|
+
required: false
|
|
286
|
+
},
|
|
287
|
+
channel: {
|
|
288
|
+
type: "string",
|
|
289
|
+
description: "live (default) or staging"
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
var McpCommandError = class extends Error {
|
|
293
|
+
name = "McpCommandError";
|
|
294
|
+
};
|
|
295
|
+
function parseChannel(value) {
|
|
296
|
+
if (value === void 0 || value === "live") return "live";
|
|
297
|
+
if (value === "staging") return "staging";
|
|
298
|
+
throw new McpCommandError(`--channel must be "live" or "staging", not "${value}".`);
|
|
299
|
+
}
|
|
300
|
+
function resolveSlug(slug) {
|
|
301
|
+
const resolved = tryResolveProject({ project: slug });
|
|
302
|
+
if (!resolved.slug) throw new McpCommandError("No project named — pass a slug (`canopy mcp serve <slug>`) or run from a project directory.");
|
|
303
|
+
return {
|
|
304
|
+
dir: resolved.dir,
|
|
305
|
+
slug: resolved.slug
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function requireSignIn() {
|
|
309
|
+
if (process.env["CANOPY_DEPLOY_TOKEN"]) throw new McpCommandError("Agent sessions use your own sign-in, not a deploy token — unset CANOPY_DEPLOY_TOKEN and run `canopy login`.");
|
|
310
|
+
if (!siteConfig().token) throw new McpCommandError("Not signed in to this site — run `canopy login` first.");
|
|
311
|
+
}
|
|
312
|
+
/** Mint through the control plane; refusals in the same words as the gateway's. */
|
|
313
|
+
function agentTokens(slug, channel) {
|
|
314
|
+
return new AgentTokenSource(async () => {
|
|
315
|
+
try {
|
|
316
|
+
return await apiRequest("POST", `/projects/${slug}/agent-token`, { channel });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (err instanceof ApiError) throw new UpstreamRefusal(err.status, err.code, explainRefusal(err.status, {
|
|
319
|
+
error: err.code,
|
|
320
|
+
detail: err.detail
|
|
321
|
+
}, slug));
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
async function connect(args) {
|
|
327
|
+
const channel = parseChannel(args.channel);
|
|
328
|
+
const { dir, slug } = resolveSlug(args.slug);
|
|
329
|
+
if (args.local) {
|
|
330
|
+
const server = liveDevServer(dir);
|
|
331
|
+
if (!server) throw new McpCommandError(`No \`canopy dev\` is running for ${slug} in ${dir} — start it there first, then restart this.`);
|
|
332
|
+
return {
|
|
333
|
+
upstream: await connectUpstream({
|
|
334
|
+
url: `${server.url}${MCP_PATH}`,
|
|
335
|
+
project: slug
|
|
336
|
+
}),
|
|
337
|
+
slug,
|
|
338
|
+
channel,
|
|
339
|
+
where: `local dev server ${server.url}`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
requireSignIn();
|
|
343
|
+
const tokens = agentTokens(slug, channel);
|
|
344
|
+
await tokens.token();
|
|
345
|
+
const upstream = await connectUpstream({
|
|
346
|
+
url: `${channel === "staging" ? stagingUrl(slug) : projectUrl(slug)}${MCP_PATH}`,
|
|
347
|
+
project: slug,
|
|
348
|
+
tokens
|
|
349
|
+
});
|
|
350
|
+
const config = siteConfig();
|
|
351
|
+
return {
|
|
352
|
+
upstream,
|
|
353
|
+
slug,
|
|
354
|
+
channel,
|
|
355
|
+
where: `${config.profileId ?? config.apiUrl}, ${channel}`
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function fail(err) {
|
|
359
|
+
const message = err instanceof McpCommandError || err instanceof UpstreamRefusal ? err.message : err instanceof Error ? err.message : String(err);
|
|
360
|
+
process.stderr.write(`canopy mcp: ${message}\n`);
|
|
361
|
+
process.exitCode = 1;
|
|
362
|
+
}
|
|
363
|
+
const serveCommand = defineCommand({
|
|
364
|
+
meta: {
|
|
365
|
+
name: "serve",
|
|
366
|
+
description: "Run the MCP server an agent client launches (stdio) for a project's tools"
|
|
367
|
+
},
|
|
368
|
+
args: {
|
|
369
|
+
...sharedArgs,
|
|
370
|
+
local: {
|
|
371
|
+
type: "boolean",
|
|
372
|
+
description: "Use the tools of the `canopy dev` running in this directory"
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
async run({ args }) {
|
|
376
|
+
console.log = console.error;
|
|
377
|
+
console.info = console.error;
|
|
378
|
+
let connection;
|
|
379
|
+
try {
|
|
380
|
+
connection = await connect(args);
|
|
381
|
+
const tools = await connection.upstream.listTools();
|
|
382
|
+
process.stderr.write(`canopy mcp: serving ${tools.length} tool${tools.length === 1 ? "" : "s"} from ${connection.slug} (${connection.where})\n`);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
fail(err);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const { upstream } = connection;
|
|
388
|
+
serveStdio(() => createBridgeServer(upstream, { version }), {
|
|
389
|
+
onerror: (err) => process.stderr.write(`canopy mcp: ${err.message}\n`),
|
|
390
|
+
maxSubscriptions: 0
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
/** The command an MCP client should run, with the site and channel spelled out. */
|
|
395
|
+
function serveInvocation(slug, channel, profile) {
|
|
396
|
+
return [
|
|
397
|
+
"mcp",
|
|
398
|
+
"serve",
|
|
399
|
+
slug,
|
|
400
|
+
...profile ? ["--profile", profile] : [],
|
|
401
|
+
...channel === "staging" ? ["--channel", "staging"] : []
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
function clientConfigLines(slug, channel, profile) {
|
|
405
|
+
const name = channel === "staging" ? `canopy-${slug}-staging` : `canopy-${slug}`;
|
|
406
|
+
const args = serveInvocation(slug, channel, profile);
|
|
407
|
+
const json = JSON.stringify({ mcpServers: { [name]: {
|
|
408
|
+
command: "canopy",
|
|
409
|
+
args
|
|
410
|
+
} } }, null, 2);
|
|
411
|
+
return [
|
|
412
|
+
"Claude Code:",
|
|
413
|
+
` claude mcp add ${name} -- canopy ${args.join(" ")}`,
|
|
414
|
+
"",
|
|
415
|
+
"Claude Desktop, Cursor and other clients (mcpServers JSON):",
|
|
416
|
+
...json.split("\n").map((line) => ` ${line}`),
|
|
417
|
+
"",
|
|
418
|
+
"The configuration holds no credentials: `canopy mcp serve` uses your `canopy login`.",
|
|
419
|
+
"If the client can't find `canopy`, use the full path from `which canopy` as the command."
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
const mcpCommand = defineCommand({
|
|
423
|
+
meta: {
|
|
424
|
+
name: "mcp",
|
|
425
|
+
description: "Let AI agents use a project's backend as MCP tools"
|
|
426
|
+
},
|
|
427
|
+
subCommands: {
|
|
428
|
+
add: defineCommand({
|
|
429
|
+
meta: {
|
|
430
|
+
name: "add",
|
|
431
|
+
description: "Check you can use a project's tools and print the config for your MCP client"
|
|
432
|
+
},
|
|
433
|
+
args: sharedArgs,
|
|
434
|
+
async run({ args }) {
|
|
435
|
+
let connection;
|
|
436
|
+
let count;
|
|
437
|
+
try {
|
|
438
|
+
connection = await connect(args);
|
|
439
|
+
count = (await connection.upstream.listTools()).length;
|
|
440
|
+
await connection.upstream.close().catch(() => {});
|
|
441
|
+
} catch (err) {
|
|
442
|
+
fail(err);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const config = siteConfig();
|
|
446
|
+
const profile = config.envOverride ? void 0 : config.profileId;
|
|
447
|
+
console.log(`\n ${connection.slug} serves ${count} tool${count === 1 ? "" : "s"} (${connection.where}).\n`);
|
|
448
|
+
for (const line of clientConfigLines(connection.slug, connection.channel, profile)) console.log(` ${line}`);
|
|
449
|
+
if (!profile) console.log("\n note: this site comes from environment overrides, so the command names no --profile.");
|
|
450
|
+
console.log("");
|
|
451
|
+
}
|
|
452
|
+
}),
|
|
453
|
+
serve: serveCommand
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
//#endregion
|
|
457
|
+
export { mcpCommand };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { n as apiDataRequest } from "./api-C4rMhE7i.mjs";
|
|
3
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
//#region src/migrations.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import { n as openBrowser, r as reportErrors } from "./ui-
|
|
2
|
+
import { n as openBrowser, r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/open.ts
|
|
5
5
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { i as apiRequest } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { join, relative, resolve, sep } from "node:path";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { i as apiRequest, o as withContext } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
//#region src/schedule.ts
|
|
5
6
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { i as apiRequest, o as withContext } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
import * as p from "@clack/prompts";
|
|
5
6
|
//#region src/secrets.ts
|
|
@@ -171,6 +171,13 @@ external API with a secret the browser can't hold, or running scheduled jobs.
|
|
|
171
171
|
It's `spa` plus Hono-style handlers in `worker/`; `canopy deploy` publishes
|
|
172
172
|
the frontend and the worker together.
|
|
173
173
|
|
|
174
|
+
**Agent tools (MCP).** A project with a `worker/` can offer its backend to
|
|
175
|
+
AI agents: `mountMcpTools(app, [...])` from `@mediamonks/canopy/mcp` wraps
|
|
176
|
+
existing routes (`route: "GET /api/notes"`) or handlers as MCP tools, and
|
|
177
|
+
people connect with `canopy mcp add <slug>` (`canopy docs agent-tools`). Mark
|
|
178
|
+
read-only tools with `annotations: { readOnlyHint: true }`; never add auth —
|
|
179
|
+
the agent already acts as the signed-in user.
|
|
180
|
+
|
|
174
181
|
Non-interactive: `canopy create "My App" --template spa`. **When unsure between
|
|
175
182
|
`static` and `spa`, pick `spa`.**
|
|
176
183
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
2
|
-
import { r as reportErrors } from "./ui-
|
|
1
|
+
import { t as version } from "./package-YGjjin4p.mjs";
|
|
2
|
+
import { r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -843,6 +843,19 @@ const IDENTITY_STAMP_HEADERS = [
|
|
|
843
843
|
"x-canopy-user-email",
|
|
844
844
|
"x-canopy-user-name"
|
|
845
845
|
];
|
|
846
|
+
/**
|
|
847
|
+
* Header the gateway attaches to a project tools call (`/__canopy/mcp`,
|
|
848
|
+
* docs/49): the caller's agent token. A credential like the session cookie —
|
|
849
|
+
* the host keeps it for `env.CANOPY.fetch` and redacts it from the bundle.
|
|
850
|
+
* It travels in its own header because on Cloud Run `Authorization` carries
|
|
851
|
+
* the Google ID token that let the gateway in.
|
|
852
|
+
*/
|
|
853
|
+
const AGENT_CREDENTIAL_HEADER = "x-canopy-agent-credential";
|
|
854
|
+
/**
|
|
855
|
+
* Gateway-stamped marker on a tools call: `agent`. Never a credential, but
|
|
856
|
+
* only the gateway may set it — it strips client copies on every dispatch.
|
|
857
|
+
*/
|
|
858
|
+
const CALLER_HEADER = "x-canopy-caller";
|
|
846
859
|
/** Cookies that authenticate to Canopy itself — the monk session and the client-page session. */
|
|
847
860
|
const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set(["canopy_session", "canopy_client"]);
|
|
848
861
|
/**
|
|
@@ -874,6 +887,7 @@ function redactCanopyCredentials(headers) {
|
|
|
874
887
|
else headers.set("cookie", kept);
|
|
875
888
|
}
|
|
876
889
|
if (headers.get("authorization")?.startsWith("Bearer cnpy_")) headers.delete("authorization");
|
|
890
|
+
headers.delete(AGENT_CREDENTIAL_HEADER);
|
|
877
891
|
return headers;
|
|
878
892
|
}
|
|
879
893
|
/**
|
|
@@ -889,4 +903,4 @@ function stampWorkerHeaders(headers, ctx) {
|
|
|
889
903
|
return headers;
|
|
890
904
|
}
|
|
891
905
|
//#endregion
|
|
892
|
-
export {
|
|
906
|
+
export { dbRouter as a, realtimeRouter as i, redactCanopyCredentials as n, META_COLUMNS as o, stampWorkerHeaders as r, blobRouter as s, CALLER_HEADER as t };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { i as apiRequest, o as withContext } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
import * as p from "@clack/prompts";
|
|
5
6
|
//#region src/staging.ts
|
|
@@ -53,6 +53,23 @@ app.get("/api/notes", async (c) => {
|
|
|
53
53
|
return c.json({ notes });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
// Let AI agents (Claude Code, Cursor…) use this backend as MCP tools, through
|
|
57
|
+
// `canopy mcp serve`. They always act as the signed-in Monk running it. Route
|
|
58
|
+
// tools wrap the routes above; uncomment to try it, then redeploy:
|
|
59
|
+
//
|
|
60
|
+
// import { mountMcpTools } from "@mediamonks/canopy/mcp";
|
|
61
|
+
//
|
|
62
|
+
// mountMcpTools(app, [
|
|
63
|
+
// {
|
|
64
|
+
// name: "list_notes",
|
|
65
|
+
// description: "List all notes in this project",
|
|
66
|
+
// route: "GET /api/notes",
|
|
67
|
+
// annotations: { readOnlyHint: true },
|
|
68
|
+
// },
|
|
69
|
+
// ]);
|
|
70
|
+
//
|
|
71
|
+
// Guide: https://docs.canopy.website/guides/agent-tools
|
|
72
|
+
|
|
56
73
|
export default {
|
|
57
74
|
fetch: app.fetch.bind(app),
|
|
58
75
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { i as apiRequest } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
const tokenCommand = defineCommand({
|
|
5
6
|
meta: {
|