appcrane-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/catalog.json ADDED
@@ -0,0 +1,1730 @@
1
+ [
2
+ {
3
+ "name": "appcrane_list_apps",
4
+ "description": "List all AppCrane apps the current user has access to. Each app includes slug, name, description, urls (production + sandbox), and the version currently live in each environment. Call this first when the user asks about \"my apps\", \"what apps exist\", or before doing anything app-specific. Non-admin users see only their assigned apps; admins (admin or platform_admin) see everything.",
5
+ "inputSchema": {
6
+ "type": "object",
7
+ "properties": {},
8
+ "additionalProperties": false
9
+ }
10
+ },
11
+ {
12
+ "name": "appcrane_get_app",
13
+ "description": "Get detailed info for a single app: URLs, current versions per environment, recent deployments, and health state. Use this when the user asks \"what's the status of <app>\", \"is <app> deployed\", or after a deploy to confirm what landed. Returns 404-equivalent error if the slug doesn't exist or the caller has no access. config.auth_mode tells you whether the app gets identity at all: `authenticated` means routes go through forward_auth and arrive at the container with X-AppCrane-* headers; `headless` means forward_auth is skipped for the whole app and those headers NEVER arrive. Check it first when debugging \"my app sees no identity headers\", but note it is per-APP, not per-request: an `authenticated` app can still have auth_bypass_paths prefixes that skip forward_auth, and a custom domain is never gated either. The request itself is authoritative — the app can read X-AppCrane-Auth-Mode (authenticated / headless / bypass), which AppCrane stamps on every route it proxies.",
14
+ "inputSchema": {
15
+ "type": "object",
16
+ "properties": {
17
+ "slug": {
18
+ "type": "string",
19
+ "description": "App slug, e.g. \"mysite\""
20
+ }
21
+ },
22
+ "required": [
23
+ "slug"
24
+ ],
25
+ "additionalProperties": false
26
+ }
27
+ },
28
+ {
29
+ "name": "appcrane_top_apps",
30
+ "description": "Top apps by distinct active users in a lookback window. Useful for \"which apps are getting the most use this week\" or \"what should I deprecate\" type questions. Sourced from app_visits which is recorded on every Caddy forward_auth (one row per user/app/day). Returns rows ordered by user count descending. Admin only.",
31
+ "inputSchema": {
32
+ "type": "object",
33
+ "properties": {
34
+ "days": {
35
+ "type": "integer",
36
+ "minimum": 1,
37
+ "maximum": 90,
38
+ "default": 7,
39
+ "description": "Lookback window. Default 7, max 90."
40
+ },
41
+ "top": {
42
+ "type": "integer",
43
+ "minimum": 1,
44
+ "maximum": 50,
45
+ "default": 10,
46
+ "description": "How many rows. Default 10, max 50."
47
+ }
48
+ },
49
+ "additionalProperties": false
50
+ }
51
+ },
52
+ {
53
+ "name": "appcrane_top_users",
54
+ "description": "Top users by distinct apps opened in a lookback window. Surfaces who the heaviest cross-app users are — handy for finding power users to interview, or spotting churn risk (a user who used 10 apps last month and 0 this week). Sourced from app_visits. Active users only. Admin only.",
55
+ "inputSchema": {
56
+ "type": "object",
57
+ "properties": {
58
+ "days": {
59
+ "type": "integer",
60
+ "minimum": 1,
61
+ "maximum": 90,
62
+ "default": 7,
63
+ "description": "Lookback window. Default 7, max 90."
64
+ },
65
+ "top": {
66
+ "type": "integer",
67
+ "minimum": 1,
68
+ "maximum": 50,
69
+ "default": 10,
70
+ "description": "How many rows. Default 10, max 50."
71
+ }
72
+ },
73
+ "additionalProperties": false
74
+ }
75
+ },
76
+ {
77
+ "name": "appcrane_get_health",
78
+ "description": "Fetch the deployed app's health endpoint server-side, bypassing AppCrane's auth proxy. Use this to validate that a deploy actually landed the expected version, or to check if the app is responding. AppCrane hits the app's configured health endpoint (default /api/health) on the internal port directly — no Caddy, no SSO redirect — and returns the response status + body. Defaults to sandbox; pass stage=\"production\" only when the user asks about prod.",
79
+ "inputSchema": {
80
+ "type": "object",
81
+ "properties": {
82
+ "slug": {
83
+ "type": "string",
84
+ "description": "App slug, e.g. \"mysite\""
85
+ },
86
+ "stage": {
87
+ "type": "string",
88
+ "enum": [
89
+ "sandbox",
90
+ "production"
91
+ ],
92
+ "default": "sandbox",
93
+ "description": "Target stage (legacy alias: env)."
94
+ }
95
+ },
96
+ "required": [
97
+ "slug"
98
+ ],
99
+ "additionalProperties": false
100
+ }
101
+ },
102
+ {
103
+ "name": "appcrane_get_secret",
104
+ "description": "List an app's secrets (encrypted env vars) with their values MASKED — safe to show in chat. For each key you get: is_set, length, a short preview (last 3 chars, rest masked; fully masked for short values), a sha256 `fingerprint` (compare two envs or detect a changed value without seeing it), and updated_at. This is what you want for \"is X set?\", \"did the key change?\", \"which vars exist?\". Does NOT return plaintext — a secret never lands in the transcript. To read one actual value, use appcrane_reveal_secret with a specific key. Defaults to sandbox; pass env=\"production\" only when the user explicitly says production. App-admin or AppCrane admin only.",
105
+ "inputSchema": {
106
+ "type": "object",
107
+ "properties": {
108
+ "slug": {
109
+ "type": "string",
110
+ "description": "App slug, e.g. \"mysite\""
111
+ },
112
+ "stage": {
113
+ "type": "string",
114
+ "enum": [
115
+ "sandbox",
116
+ "production"
117
+ ],
118
+ "default": "sandbox",
119
+ "description": "Target stage (legacy alias: env)."
120
+ }
121
+ },
122
+ "required": [
123
+ "slug"
124
+ ],
125
+ "additionalProperties": false
126
+ }
127
+ },
128
+ {
129
+ "name": "appcrane_reveal_secret",
130
+ "description": "Reveal the PLAINTEXT of ONE secret by key. Use ONLY when the user explicitly needs the actual value — it will appear in this conversation transcript, so treat the transcript as sensitive afterward (and consider rotating the secret if the transcript may be stored/shared). For checking whether a var is set, comparing values, or seeing what exists, use appcrane_get_secret (masked) instead — do NOT reveal just to inspect config. Single key only — it never dumps the whole env. Every reveal is audit-logged. Defaults to sandbox; env=\"production\" only when the user explicitly asks. App-admin or AppCrane admin only.",
131
+ "inputSchema": {
132
+ "type": "object",
133
+ "properties": {
134
+ "slug": {
135
+ "type": "string",
136
+ "description": "App slug, e.g. \"mysite\""
137
+ },
138
+ "stage": {
139
+ "type": "string",
140
+ "enum": [
141
+ "sandbox",
142
+ "production"
143
+ ],
144
+ "default": "sandbox",
145
+ "description": "Target stage (legacy alias: env)."
146
+ },
147
+ "key": {
148
+ "type": "string",
149
+ "description": "Exact env var name to reveal (e.g. \"RESEND_API_KEY\"). One key per call."
150
+ }
151
+ },
152
+ "required": [
153
+ "slug",
154
+ "key"
155
+ ],
156
+ "additionalProperties": false
157
+ }
158
+ },
159
+ {
160
+ "name": "appcrane_deploy",
161
+ "description": "Trigger a deployment — this IS how you \"update an env to the latest\". For github and managed apps it pulls the latest commit from the app's configured branch on GitHub (server-side, using the app's stored credentials — you do NOT need your own github token or to push/upload anything), builds a fresh Docker image, and swaps in a new container. Use it whenever the user says things like \"update sandbox to the latest\", \"deploy the newest version\", \"pull my latest github changes\", or \"redeploy\". Returns a deployment ID; use appcrane_get_logs to monitor progress. Defaults to sandbox; production requires explicit confirmation from the user.",
162
+ "inputSchema": {
163
+ "type": "object",
164
+ "properties": {
165
+ "slug": {
166
+ "type": "string",
167
+ "description": "App slug to deploy"
168
+ },
169
+ "stage": {
170
+ "type": "string",
171
+ "enum": [
172
+ "sandbox",
173
+ "production"
174
+ ],
175
+ "default": "sandbox",
176
+ "description": "Target stage (legacy alias: env)."
177
+ }
178
+ },
179
+ "required": [
180
+ "slug"
181
+ ],
182
+ "additionalProperties": false
183
+ }
184
+ },
185
+ {
186
+ "name": "appcrane_list_releases",
187
+ "description": "List the deploy/release history for an app + env, newest first — each release is id, version, commit, status (live / rolled_back / failed / pending), who deployed it, and when. Use this to see what is live and to pick a target for appcrane_rollback. App access required.",
188
+ "inputSchema": {
189
+ "type": "object",
190
+ "properties": {
191
+ "slug": {
192
+ "type": "string"
193
+ },
194
+ "stage": {
195
+ "type": "string",
196
+ "enum": [
197
+ "sandbox",
198
+ "production"
199
+ ],
200
+ "default": "sandbox",
201
+ "description": "Target stage (legacy alias: env)."
202
+ },
203
+ "limit": {
204
+ "type": "integer",
205
+ "minimum": 1,
206
+ "maximum": 50,
207
+ "description": "Max rows (default 10)."
208
+ }
209
+ },
210
+ "required": [
211
+ "slug"
212
+ ],
213
+ "additionalProperties": false
214
+ }
215
+ },
216
+ {
217
+ "name": "appcrane_rollback",
218
+ "description": "Roll an env back to a prior release. Pass deployment_id (from appcrane_list_releases) to target a specific release, or omit it to roll back to the immediately previous one. Re-runs that release from its recorded build (re-uses the cached per-commit image — no rebuild when it is still retained) and health-checks it. Records a NEW deployment and marks the previous live one rolled_back. Owner-only (or global admin).",
219
+ "inputSchema": {
220
+ "type": "object",
221
+ "properties": {
222
+ "slug": {
223
+ "type": "string"
224
+ },
225
+ "stage": {
226
+ "type": "string",
227
+ "enum": [
228
+ "sandbox",
229
+ "production"
230
+ ],
231
+ "default": "sandbox",
232
+ "description": "Target stage (legacy alias: env)."
233
+ },
234
+ "deployment_id": {
235
+ "type": "integer",
236
+ "description": "Target release id. Omit to roll back to the previous release."
237
+ }
238
+ },
239
+ "required": [
240
+ "slug"
241
+ ],
242
+ "additionalProperties": false
243
+ }
244
+ },
245
+ {
246
+ "name": "appcrane_promote",
247
+ "description": "Promote the current live SANDBOX release to production — the gated sandbox→prod path. Refuses unless sandbox is live AND currently healthy (you do not ship a broken sandbox to prod), and the promoted prod release is health-checked with auto-revert. For github apps this rebuilds production from the EXACT sandbox commit; for managed/upload apps it copies the exact tested sandbox release. Owner-only (or global admin).",
248
+ "inputSchema": {
249
+ "type": "object",
250
+ "properties": {
251
+ "slug": {
252
+ "type": "string"
253
+ }
254
+ },
255
+ "required": [
256
+ "slug"
257
+ ],
258
+ "additionalProperties": false
259
+ }
260
+ },
261
+ {
262
+ "name": "appcrane_list_requests",
263
+ "description": "List enhancement requests filed against an app via the AppCrane intake form. Use this when the user asks \"what should I work on?\", \"what's queued for X?\", or wants to pick up tickets. Returns id, message, app_slug, submitter, and bucket. Buckets: triage (unclaimed), in_progress (someone is working on it), shipped (merged + deployed), validated (requester confirmed). Filter by bucket=\"triage\" to find work to pick up.",
264
+ "inputSchema": {
265
+ "type": "object",
266
+ "properties": {
267
+ "slug": {
268
+ "type": "string",
269
+ "description": "Filter by app slug. Omit to see across all accessible apps."
270
+ },
271
+ "bucket": {
272
+ "type": "string",
273
+ "enum": [
274
+ "triage",
275
+ "in_progress",
276
+ "shipped",
277
+ "validated"
278
+ ],
279
+ "description": "Filter to one bucket. Most useful: \"triage\" for unclaimed work."
280
+ },
281
+ "limit": {
282
+ "type": "number",
283
+ "default": 20,
284
+ "minimum": 1,
285
+ "maximum": 100
286
+ }
287
+ },
288
+ "additionalProperties": false
289
+ }
290
+ },
291
+ {
292
+ "name": "appcrane_set_request_status",
293
+ "description": "Move a request through the lifecycle: triage → in_progress → shipped → validated. Use this when the user says \"I'll take #42\" (set to in_progress), after merging a PR (set to shipped), or after confirming a fix works (set to validated). Validated requests are considered closed. Requires app-admin or AppCrane admin role.",
294
+ "inputSchema": {
295
+ "type": "object",
296
+ "properties": {
297
+ "id": {
298
+ "type": "number",
299
+ "description": "Request id (the # column)."
300
+ },
301
+ "bucket": {
302
+ "type": "string",
303
+ "enum": [
304
+ "triage",
305
+ "in_progress",
306
+ "shipped",
307
+ "validated"
308
+ ],
309
+ "description": "Target bucket."
310
+ }
311
+ },
312
+ "required": [
313
+ "id",
314
+ "bucket"
315
+ ],
316
+ "additionalProperties": false
317
+ }
318
+ },
319
+ {
320
+ "name": "appcrane_ls",
321
+ "description": "List files inside a running app container at a specific path. Use to verify what actually got built / what files made it into the deployed image. Read-only; bound to safe roots (/app and /data only). Returns the directory listing as text.",
322
+ "inputSchema": {
323
+ "type": "object",
324
+ "properties": {
325
+ "slug": {
326
+ "type": "string"
327
+ },
328
+ "stage": {
329
+ "type": "string",
330
+ "enum": [
331
+ "sandbox",
332
+ "production"
333
+ ],
334
+ "default": "sandbox",
335
+ "description": "Target stage (legacy alias: env)."
336
+ },
337
+ "path": {
338
+ "type": "string",
339
+ "description": "Absolute path inside the container, must start with /app or /data",
340
+ "default": "/app"
341
+ }
342
+ },
343
+ "required": [
344
+ "slug"
345
+ ],
346
+ "additionalProperties": false
347
+ }
348
+ },
349
+ {
350
+ "name": "appcrane_cat",
351
+ "description": "Print the contents of a file inside a running app container. Read-only; bound to safe roots (/app and /data only). Refuses files larger than 256KB; truncate by reading the first N bytes via path tricks if you need a tail.",
352
+ "inputSchema": {
353
+ "type": "object",
354
+ "properties": {
355
+ "slug": {
356
+ "type": "string"
357
+ },
358
+ "stage": {
359
+ "type": "string",
360
+ "enum": [
361
+ "sandbox",
362
+ "production"
363
+ ],
364
+ "default": "sandbox",
365
+ "description": "Target stage (legacy alias: env)."
366
+ },
367
+ "path": {
368
+ "type": "string",
369
+ "description": "Absolute path inside the container, must start with /app or /data"
370
+ }
371
+ },
372
+ "required": [
373
+ "slug",
374
+ "path"
375
+ ],
376
+ "additionalProperties": false
377
+ }
378
+ },
379
+ {
380
+ "name": "appcrane_stage_from_url",
381
+ "description": "Stage a file by having AppCrane DOWNLOAD it, and return its token. THE CHEAPEST WAY to get a large artifact in: the bytes go host-to-host and never pass through your context, so a 600KB bundle costs the same handful of tokens as a 6KB one. Prefer this over appcrane_stage_chunk for anything bigger than a small text file. Give it a URL your build already produces — a GitHub release asset, an S3/R2 presigned URL, any https link the server can reach. Pass sha256 to have the downloaded bytes verified before a token is issued. Then deploy with appcrane_deploy_artifact. https only; redirects are not followed; private and link-local addresses are refused.",
382
+ "inputSchema": {
383
+ "type": "object",
384
+ "properties": {
385
+ "url": {
386
+ "type": "string",
387
+ "description": "Direct https URL to the file. Must not redirect — give the final URL."
388
+ },
389
+ "filename": {
390
+ "type": "string",
391
+ "description": "Name to stage it under. For a deploy it must end in .zip, .tar.gz or .tgz. Defaults to the last path segment."
392
+ },
393
+ "sha256": {
394
+ "type": "string",
395
+ "description": "Optional hex SHA-256 of the file, verified before the token is issued."
396
+ }
397
+ },
398
+ "required": [
399
+ "url"
400
+ ],
401
+ "additionalProperties": false
402
+ }
403
+ },
404
+ {
405
+ "name": "appcrane_stage_chunk",
406
+ "description": "Upload one part of a SMALL file to AppCrane over MCP. For anything bigger than a few KB use appcrane_stage_from_url instead — the bytes here are emitted by the model, so they cost output tokens per character and fail on a single typo. Capped at 8 parts for that reason. Split the file into parts small enough for a tool call (~256KB of base64 each is comfortable), send each with the same `session` and `of`, then call appcrane_stage_assemble. Parts may be sent in any order and re-sent to replace a corrupted one; the reply lists which parts are still missing. Use encoding=\"base64\" for anything binary. Pass sha256 of THIS part to have it verified on arrival.",
407
+ "inputSchema": {
408
+ "type": "object",
409
+ "properties": {
410
+ "session": {
411
+ "type": "string",
412
+ "description": "Opaque id grouping the parts of one file. Any unique string; reuse it for every part."
413
+ },
414
+ "part": {
415
+ "type": "integer",
416
+ "minimum": 1,
417
+ "description": "1-based part number."
418
+ },
419
+ "of": {
420
+ "type": "integer",
421
+ "minimum": 1,
422
+ "description": "Total number of parts. Identical across every part of a session."
423
+ },
424
+ "content": {
425
+ "type": "string",
426
+ "description": "This part's bytes, encoded per `encoding`."
427
+ },
428
+ "encoding": {
429
+ "type": "string",
430
+ "enum": [
431
+ "utf-8",
432
+ "base64"
433
+ ],
434
+ "description": "Defaults to utf-8. Use base64 for binary."
435
+ },
436
+ "sha256": {
437
+ "type": "string",
438
+ "description": "Optional hex SHA-256 of this part, verified on arrival."
439
+ }
440
+ },
441
+ "required": [
442
+ "session",
443
+ "part",
444
+ "of",
445
+ "content"
446
+ ],
447
+ "additionalProperties": false
448
+ }
449
+ },
450
+ {
451
+ "name": "appcrane_stage_assemble",
452
+ "description": "Join the parts pushed with appcrane_stage_chunk into one staged file and return its token. Hand that token to appcrane_deploy_artifact to deploy it — that pair is a complete, MCP-native deploy for an app with no repo, and it does not touch GitHub. Pass sha256 of the WHOLE original file to have the reassembled bytes verified before the token is issued; without it you are trusting that every part arrived intact.",
453
+ "inputSchema": {
454
+ "type": "object",
455
+ "properties": {
456
+ "session": {
457
+ "type": "string",
458
+ "description": "The session id used for the parts."
459
+ },
460
+ "filename": {
461
+ "type": "string",
462
+ "description": "Name for the assembled file. For a deploy it must end in .zip, .tar.gz or .tgz."
463
+ },
464
+ "sha256": {
465
+ "type": "string",
466
+ "description": "Optional hex SHA-256 of the whole original file, verified before the token is issued."
467
+ }
468
+ },
469
+ "required": [
470
+ "session",
471
+ "filename"
472
+ ],
473
+ "additionalProperties": false
474
+ }
475
+ },
476
+ {
477
+ "name": "appcrane_rename_app",
478
+ "description": "Rename an app's slug. The slug is its URL, its container name and its data directory, so this changes all three — but it is NOT destructive: deploy history, env vars, ports, per-app roles and grants are keyed on the app id, not the slug, and survive untouched. The old slug is kept as a redirect unless redirect=false. Platform admin. Use this instead of recreating an app under a new name, which is what loses the history. To free a slug held by an app you no longer want, rename THAT app out of the way with redirect=false rather than deleting it — deleting clears the database rows but leaves data/apps/<slug> on disk, and the rename then refuses because the directory is still there.",
479
+ "inputSchema": {
480
+ "type": "object",
481
+ "properties": {
482
+ "slug": {
483
+ "type": "string",
484
+ "description": "Current app slug."
485
+ },
486
+ "new_slug": {
487
+ "type": "string",
488
+ "description": "New slug: lowercase letters, digits and dashes, starting with a letter or digit."
489
+ },
490
+ "redirect": {
491
+ "type": "boolean",
492
+ "default": true,
493
+ "description": "Keep the old slug redirecting to the new one. Pass false when you are freeing the old slug for another app to take."
494
+ }
495
+ },
496
+ "required": [
497
+ "slug",
498
+ "new_slug"
499
+ ],
500
+ "additionalProperties": false
501
+ }
502
+ },
503
+ {
504
+ "name": "appcrane_deploy_artifact",
505
+ "description": "Deploy a release from an uploaded BUNDLE instead of from git. For an app with no GitHub repo, and the fallback when the repo path is unavailable — an expired service-account PAT blocks every managed-repo write, and this route does not touch GitHub at all. Two steps: (1) upload the bundle with `curl -F file=@dist.zip -H \"X-API-Key: <your dhk_mcp_ key>\" https://<host>/api/files/staged` — your MCP key IS allowed on that endpoint; it returns { token, sha256, size_bytes }. (2) Call this tool with that token. Accepts .zip, .tar.gz, .tgz, up to the staged-file limit. The release is identified by a SHA-256 AppCrane computes over the bytes, recorded as commit_hash \"sha256:<digest>\"; the tool re-hashes the staged bytes and refuses if they no longer match what was staged. Returns that digest — compare it against the one you computed locally. Deploys to sandbox unless env=production.",
506
+ "inputSchema": {
507
+ "type": "object",
508
+ "properties": {
509
+ "slug": {
510
+ "type": "string",
511
+ "description": "Target app slug"
512
+ },
513
+ "stage": {
514
+ "type": "string",
515
+ "enum": [
516
+ "sandbox",
517
+ "production"
518
+ ],
519
+ "default": "sandbox",
520
+ "description": "Target stage (legacy alias: env)."
521
+ },
522
+ "token": {
523
+ "type": "string",
524
+ "description": "Token returned by POST /api/files/staged"
525
+ },
526
+ "commit_message": {
527
+ "type": "string",
528
+ "description": "Optional release note, shown in the deploy history"
529
+ },
530
+ "commit_sha": {
531
+ "type": "string",
532
+ "description": "Optional git SHA from the machine that BUILT the bundle. Recorded as context only — it is not verified and does not become the release identity."
533
+ }
534
+ },
535
+ "required": [
536
+ "slug",
537
+ "token"
538
+ ],
539
+ "additionalProperties": false
540
+ }
541
+ },
542
+ {
543
+ "name": "appcrane_push_staged_file",
544
+ "description": "Move a previously-staged file into a running container at a path under /app or /data. THE WAY TO GET LARGE BINARIES (DMGs, datasets, bundles) into a container when they're too big to inline through appcrane_cp. Two steps: (1) upload the bytes with a plain multipart POST to `curl -F file=@local.dmg -H \"X-API-Key: <your dhk_mcp_ key>\" https://<host>/api/files/staged` — your MCP key IS allowed on this endpoint (v2.10.6+); it returns { token, sha256, size_bytes }. (2) Call this tool with that token and a dest path. The container must be running. Path is validated (no \"..\", must start with /app or /data). The staged blob is deleted on success.",
545
+ "inputSchema": {
546
+ "type": "object",
547
+ "properties": {
548
+ "slug": {
549
+ "type": "string",
550
+ "description": "Target app slug"
551
+ },
552
+ "stage": {
553
+ "type": "string",
554
+ "enum": [
555
+ "sandbox",
556
+ "production"
557
+ ],
558
+ "default": "sandbox",
559
+ "description": "Target stage (legacy alias: env)."
560
+ },
561
+ "token": {
562
+ "type": "string",
563
+ "description": "Token returned by POST /api/files/staged"
564
+ },
565
+ "dest": {
566
+ "type": "string",
567
+ "description": "Absolute container path under /app or /data — destination file or directory"
568
+ }
569
+ },
570
+ "required": [
571
+ "slug",
572
+ "token",
573
+ "dest"
574
+ ],
575
+ "additionalProperties": false
576
+ }
577
+ },
578
+ {
579
+ "name": "appcrane_wait_deploy",
580
+ "description": "Block until a deployment reaches a terminal state (live / failed / rolled_back), then return its final status. Use after appcrane_deploy instead of polling appcrane_get_logs in a loop. Returns immediately if the deployment is already terminal. Defaults to 180s timeout, hard-capped at 600s. On timeout, returns { status: \"pending\", timed_out: true } so the caller can decide whether to keep waiting.",
581
+ "inputSchema": {
582
+ "type": "object",
583
+ "properties": {
584
+ "deployment_id": {
585
+ "type": "number",
586
+ "description": "Deployment id from appcrane_deploy"
587
+ },
588
+ "timeout_sec": {
589
+ "type": "number",
590
+ "description": "How long to wait. Default 180s, max 600s.",
591
+ "default": 180
592
+ }
593
+ },
594
+ "required": [
595
+ "deployment_id"
596
+ ],
597
+ "additionalProperties": false
598
+ }
599
+ },
600
+ {
601
+ "name": "appcrane_get_deploy_log",
602
+ "description": "Read the deploy/build log for a specific deployment — the output that came out of clone / npm install / docker build / health-validate, BEFORE the container started running. This is what you want when a deploy fails fast (1-2 second failures are almost always pre-build errors that never reach the runtime container, so appcrane_get_logs has nothing to show). Pass a deployment_id from appcrane_deploy / appcrane_get_app.recent_deployments, OR omit it and pass slug+env to get the latest deployment's log.",
603
+ "inputSchema": {
604
+ "type": "object",
605
+ "properties": {
606
+ "deployment_id": {
607
+ "type": "integer",
608
+ "description": "Specific deployment id. Preferred — unambiguous."
609
+ },
610
+ "slug": {
611
+ "type": "string",
612
+ "description": "App slug. Required when deployment_id is not given."
613
+ },
614
+ "stage": {
615
+ "type": "string",
616
+ "enum": [
617
+ "sandbox",
618
+ "production"
619
+ ],
620
+ "description": "Required when deployment_id is not given. Target stage (legacy alias: env)."
621
+ },
622
+ "tail": {
623
+ "type": "integer",
624
+ "minimum": 1,
625
+ "maximum": 5000,
626
+ "default": 500,
627
+ "description": "Return only the last N lines. Defaults to 500; full log can be many KB on a long build."
628
+ }
629
+ },
630
+ "additionalProperties": false
631
+ }
632
+ },
633
+ {
634
+ "name": "appcrane_get_logs",
635
+ "description": "Get recent runtime logs from a running app container (docker logs). Use this for runtime issues — once the container is up. Returns the most recent N lines (default 100, max 1000). Pass search to filter to lines containing a substring (case-insensitive). NOT the right tool for fast deploy failures (1-2 second exits, \"no such container\" errors): those happen during clone / npm install / docker build / health-validate, BEFORE any container exists. Use appcrane_get_deploy_log for that.",
636
+ "inputSchema": {
637
+ "type": "object",
638
+ "properties": {
639
+ "slug": {
640
+ "type": "string"
641
+ },
642
+ "stage": {
643
+ "type": "string",
644
+ "enum": [
645
+ "sandbox",
646
+ "production"
647
+ ],
648
+ "default": "sandbox",
649
+ "description": "Target stage (legacy alias: env)."
650
+ },
651
+ "lines": {
652
+ "type": "number",
653
+ "default": 100,
654
+ "minimum": 1,
655
+ "maximum": 1000
656
+ },
657
+ "search": {
658
+ "type": "string",
659
+ "description": "Filter to lines containing this substring (case-insensitive)"
660
+ }
661
+ },
662
+ "required": [
663
+ "slug"
664
+ ],
665
+ "additionalProperties": false
666
+ }
667
+ },
668
+ {
669
+ "name": "appcrane_create_app",
670
+ "description": "Register a new app in AppCrane, either from a GitHub repository or from a prebuilt container image. Use this only after the user has explicitly confirmed they want to onboard a new app and told you which source to use — a real github URL, or an image reference for source_type='image'. Allocates ports, creates the data directories, configures Caddy routing, and starts health checks. After this returns, call appcrane_set_secret to set any required secrets, then appcrane_deploy to ship the first build. For an image app there is nothing to build: the deploy pulls image_ref and starts it, so also pass container_port and health_path unless the image happens to match AppCrane's own 3000 + /api/health defaults. Requires the create-apps permission (global admins, or any role a platform admin granted at Settings → Roles).",
671
+ "inputSchema": {
672
+ "type": "object",
673
+ "properties": {
674
+ "name": {
675
+ "type": "string",
676
+ "description": "Display name (shown in dashboard)"
677
+ },
678
+ "slug": {
679
+ "type": "string",
680
+ "description": "URL-safe identifier — lowercase letters, digits, dashes; must start with a letter or digit. Lives at /<slug>/."
681
+ },
682
+ "source_type": {
683
+ "type": "string",
684
+ "enum": [
685
+ "github",
686
+ "image"
687
+ ],
688
+ "description": "'github' (default) clones and builds a repo; 'image' runs a prebuilt image and never builds. Implied when image_ref is passed."
689
+ },
690
+ "github_url": {
691
+ "type": "string",
692
+ "description": "GitHub repo URL, e.g. https://github.com/me/mysite. Required unless source_type is 'image'."
693
+ },
694
+ "branch": {
695
+ "type": "string",
696
+ "description": "Branch to track. Default: main",
697
+ "default": "main"
698
+ },
699
+ "image_ref": {
700
+ "type": "string",
701
+ "description": "Prebuilt container image to run instead of building from source, e.g. \"odoo:19\" or \"ghcr.io/owner/app@sha256:<64 hex>\". Sets source_type='image'. The reference MUST name a version: a bare name (\"odoo\") and an explicit \":latest\" are both REFUSED, because AppCrane records the resolved digest as the deployment's identity and against an unpinned tag that record is wrong as soon as upstream moves. A digest is the real pin; a version tag (\"odoo:19\") is accepted and is how you pick up a patch release on redeploy. Credentials never go in the reference — a private registry authenticates through the Docker daemon's own credential store, set up out of band."
702
+ },
703
+ "container_port": {
704
+ "type": "integer",
705
+ "minimum": 1,
706
+ "maximum": 65535,
707
+ "description": "Port the image listens on inside the container. Omit only if the image really listens on 3000 (what an AppCrane-built image does); a third-party image usually does not — odoo is 8069, nginx is 80. Getting this wrong makes the app unreachable, not slow."
708
+ },
709
+ "health_path": {
710
+ "type": "string",
711
+ "description": "HTTP path AppCrane health-checks. Omit only if the image serves /api/health (the AppCrane-built default). A stock image will not, and a health check against a 404 marks a working app unhealthy."
712
+ },
713
+ "description": {
714
+ "type": "string"
715
+ },
716
+ "domain": {
717
+ "type": "string",
718
+ "description": "Optional custom domain. If omitted, the app lives under CRANE_DOMAIN/<slug>/."
719
+ },
720
+ "github_token": {
721
+ "type": "string",
722
+ "description": "GitHub PAT for private repos. Stored encrypted; only used to clone."
723
+ },
724
+ "max_ram_mb": {
725
+ "type": "number",
726
+ "description": "Per-container memory cap. Default: 512."
727
+ },
728
+ "max_cpu_percent": {
729
+ "type": "number",
730
+ "description": "Per-container CPU cap. Default: 50."
731
+ }
732
+ },
733
+ "required": [
734
+ "name",
735
+ "slug"
736
+ ],
737
+ "additionalProperties": false
738
+ }
739
+ },
740
+ {
741
+ "name": "appcrane_update_app",
742
+ "description": "Patch fields on an existing app. Use this to fix a missing github_url after the fact, change branch, rotate the github_token, retag with category/visibility, point an image app at a new image_ref, or adjust resource limits — anything you would otherwise need direct DB access for. Only includes fields you pass; omitted fields are left alone. To clear a string field pass an empty string. Returns the same shape as appcrane_get_app.",
743
+ "inputSchema": {
744
+ "type": "object",
745
+ "properties": {
746
+ "slug": {
747
+ "type": "string",
748
+ "description": "App slug to update."
749
+ },
750
+ "name": {
751
+ "type": "string"
752
+ },
753
+ "description": {
754
+ "type": "string"
755
+ },
756
+ "category": {
757
+ "type": "string"
758
+ },
759
+ "domain": {
760
+ "type": "string"
761
+ },
762
+ "source_type": {
763
+ "type": "string",
764
+ "enum": [
765
+ "github",
766
+ "managed",
767
+ "upload",
768
+ "image"
769
+ ]
770
+ },
771
+ "github_url": {
772
+ "type": "string",
773
+ "description": "github.com URL of the source repo. Pass empty string to clear."
774
+ },
775
+ "branch": {
776
+ "type": "string"
777
+ },
778
+ "image_ref": {
779
+ "type": "string",
780
+ "description": "Prebuilt container image to run instead of building from source, e.g. \"odoo:19\" or \"ghcr.io/owner/app@sha256:<64 hex>\". Sets source_type='image'. The reference MUST name a version: a bare name (\"odoo\") and an explicit \":latest\" are both REFUSED, because AppCrane records the resolved digest as the deployment's identity and against an unpinned tag that record is wrong as soon as upstream moves. A digest is the real pin; a version tag (\"odoo:19\") is accepted and is how you pick up a patch release on redeploy. Credentials never go in the reference — a private registry authenticates through the Docker daemon's own credential store, set up out of band. Pass empty string to clear."
781
+ },
782
+ "container_port": {
783
+ "type": "integer",
784
+ "minimum": 1,
785
+ "maximum": 65535,
786
+ "description": "Port the image listens on inside the container. Omit only if the image really listens on 3000 (what an AppCrane-built image does); a third-party image usually does not — odoo is 8069, nginx is 80. Getting this wrong makes the app unreachable, not slow."
787
+ },
788
+ "health_path": {
789
+ "type": "string",
790
+ "description": "HTTP path AppCrane health-checks. Omit only if the image serves /api/health (the AppCrane-built default). A stock image will not, and a health check against a 404 marks a working app unhealthy. Pass empty string to restore the default."
791
+ },
792
+ "github_token": {
793
+ "type": "string",
794
+ "description": "PAT for private clones. Stored encrypted (AES-256-GCM). Omit to leave the existing token alone; pass empty string to clear it; pass a value to rotate."
795
+ },
796
+ "visibility": {
797
+ "type": "string",
798
+ "enum": [
799
+ "public",
800
+ "private",
801
+ "hidden"
802
+ ]
803
+ },
804
+ "public_access": {
805
+ "type": "integer",
806
+ "enum": [
807
+ 0,
808
+ 1
809
+ ]
810
+ },
811
+ "image_retention": {
812
+ "type": "integer",
813
+ "minimum": 0,
814
+ "maximum": 50
815
+ },
816
+ "frame_ancestors": {
817
+ "type": "string"
818
+ },
819
+ "auth_bypass_paths": {
820
+ "type": "array",
821
+ "maxItems": 10,
822
+ "items": {
823
+ "type": "string"
824
+ },
825
+ "description": "v2.7.27: array of path prefixes (e.g. [\"/ws/local-runner\"]) that bypass SSO forward_auth on this app. Requests under these prefixes reach the container with NO X-AppCrane-* identity headers — the app authenticates them itself (e.g. token in query string). Caddy suppresses access logging for these paths to prevent token leakage to log storage. Pass [] or null to clear."
826
+ },
827
+ "max_ram_mb": {
828
+ "type": "number",
829
+ "description": "Per-container memory cap."
830
+ },
831
+ "max_cpu_percent": {
832
+ "type": "number",
833
+ "description": "Per-container CPU cap (0-100)."
834
+ }
835
+ },
836
+ "required": [
837
+ "slug"
838
+ ],
839
+ "additionalProperties": false
840
+ }
841
+ },
842
+ {
843
+ "name": "appcrane_set_app_meta",
844
+ "description": "Set an app's category, visibility, auth_mode, and/or auth_bypass_paths — the owner self-service fields (same controls the dashboard Launcher exposes to owners). Owner of the app (or global admin) required. visibility is one of public / private / hidden. auth_mode is `authenticated` (default — all routes go through AppCrane SSO) or `headless` (the app bypasses forward_auth ENTIRELY and is reachable without identity — right tool for telemetry ingest, public webhooks, status pages; the app's own server is responsible for any payload-level authn). A headless app is still served BY CADDY over HTTP — TLS, security headers and access logging all still apply; it is not a raw port. An app that does not speak HTTP at all needs ingress_type='tcp', and an app that speaks HTTP AND needs a second raw port for non-HTTP clients needs ingress_type='dual' (both via appcrane_set_app_ingress, platform admin only) — different and far more exposed things. auth_bypass_paths (v2.7.27+) is an array of path prefixes (e.g. [\"/ws/local-runner\"]) that bypass SSO on this app only — narrower than headless mode; the app authenticates those paths itself (e.g. token in query string). The platform strips incoming X-AppCrane-* headers on bypass paths (forgery defense intact) and suppresses access logging for them (token-in-query never sits in log storage). Owners may only assign an EXISTING category; creating a brand-new category is reserved for global admins. For powerful fields (github_url, branch, token, source_type, resource limits) use appcrane_update_app (admin only).",
845
+ "inputSchema": {
846
+ "type": "object",
847
+ "properties": {
848
+ "slug": {
849
+ "type": "string",
850
+ "description": "App slug."
851
+ },
852
+ "category": {
853
+ "type": "string",
854
+ "description": "Category/tag. Owners must pick one already in use; pass empty string to clear."
855
+ },
856
+ "visibility": {
857
+ "type": "string",
858
+ "enum": [
859
+ "public",
860
+ "private",
861
+ "hidden"
862
+ ],
863
+ "description": "public = anyone; private = assigned users; hidden = not discoverable."
864
+ },
865
+ "auth_mode": {
866
+ "type": "string",
867
+ "enum": [
868
+ "authenticated",
869
+ "headless"
870
+ ],
871
+ "description": "authenticated = AppCrane SSO + per-app role checks; headless = NO auth at the proxy (the entire app is reachable by anyone on the internet)."
872
+ },
873
+ "auth_bypass_paths": {
874
+ "type": "array",
875
+ "maxItems": 10,
876
+ "items": {
877
+ "type": "string"
878
+ },
879
+ "description": "v2.7.27: array of path prefixes (e.g. [\"/ws/local-runner\"]) that bypass SSO forward_auth on this app. Requests under these prefixes reach the container with NO X-AppCrane-* identity headers — the app authenticates them itself. Caddy suppresses access logging for these paths to prevent query-string-token leakage. Pass [] or null to clear."
880
+ },
881
+ "domain": {
882
+ "type": "string",
883
+ "description": "v2.10.0: custom domain (e.g. \"raise.glick.run\") that serves this app at the ROOT of that domain with NO AppCrane SSO and NO topbar — the app does its own auth. Maps to production. Requires the domain's DNS to point at this host (Caddy auto-provisions TLS). Pass \"\" or null to remove. The /<slug> path under the platform domain stays."
884
+ }
885
+ },
886
+ "required": [
887
+ "slug"
888
+ ],
889
+ "additionalProperties": false
890
+ }
891
+ },
892
+ {
893
+ "name": "appcrane_get_app_ingress",
894
+ "description": "Read HOW an app is reachable. ingress_type is `http` (default — every request goes through Caddy: TLS, AppCrane SSO/forward_auth, X-AppCrane-* identity headers, security headers, access logs), `tcp` (the container port is published DIRECTLY on the host at public_port, with Caddy not in the path at all — for apps that do not speak HTTP, e.g. a forward/CONNECT proxy handing back a raw tunnel), or `dual` (v2.45.0 — BOTH at once: an ordinary HTTP control plane still served through Caddy on container port 3000 with every Caddy control intact, PLUS a raw data plane published at 0.0.0.0:<public_port> -> <data_plane_port>, a DIFFERENT port inside the same container, with none of them). For a dual app the two planes have different security properties and the answer to \"is this app behind AppCrane auth\" is different for each — read `exposure.control_plane` and `exposure.data_plane` rather than the enum. Read this before debugging \"why does my app see no identity headers\" or \"what port do clients connect to\". IMPORTANT — this is NOT auth_mode: auth_mode=`headless` still goes through Caddy and only skips forward_auth, so it keeps TLS, security headers and request logging; a published port under `tcp` or `dual` keeps NONE of that. Requires access to the app.",
895
+ "inputSchema": {
896
+ "type": "object",
897
+ "properties": {
898
+ "slug": {
899
+ "type": "string",
900
+ "description": "App slug."
901
+ }
902
+ },
903
+ "required": [
904
+ "slug"
905
+ ],
906
+ "additionalProperties": false
907
+ }
908
+ },
909
+ {
910
+ "name": "appcrane_set_app_ingress",
911
+ "description": "Switch an app between HTTP ingress, raw TCP ingress and dual (both), and choose its public host port. PLATFORM ADMIN ONLY — this is not an owner self-service field like auth_mode. ingress_type=`tcp` publishes 0.0.0.0:<public_port> -> the container in ADDITION to the loopback publish every app has, so clients connect straight to the host and Caddy never sees the traffic: no forward_auth/SSO, no identity headers, no per-request audit, no rate limiting, no security headers, no TLS from AppCrane. Every control AppCrane has assumes Caddy is the only door; this adds a second one the platform does not control, and the app then owns authentication completely. Do NOT reach for this to make an app reachable without login — that is auth_mode=`headless`, which still goes through Caddy and keeps TLS, security headers and logging. Use `tcp` ONLY when the app does not speak HTTP at all (a forward/CONNECT proxy tunnels raw bytes; no reverse proxy can express that). ingress_type=`dual` (v2.45.0) is for an app that is BOTH: its HTTP control plane keeps being served through Caddy on container port 3000 with every control intact (and stays the plane its health check probes), while a SECOND listener inside the same container — data_plane_port, which is REQUIRED and must not be 3000 — is published raw at 0.0.0.0:<public_port> with none of them. Setting data_plane_port to 3000 is refused: that would republish the ordinary HTTP origin Caddy fronts, unauthenticated and unaudited, which is the exact surface Caddy is in the path to protect. Omit public_port to keep the existing allocation, or to have the lowest free port in 31000-31999 allocated — a dedicated band so the operator firewalls one predictable block; an explicitly NAMED port may be anything in 1024-65535, because clients are configured with a port by hand or by MDM and a number like 8080 is often not the platform's to choose. Naming a port outside the auto band is legal and safe — the guards that matter (the WHATWG blocked list, AppCrane's own listening port, collisions with slot-derived backend ports, and one-app-per-host-port) apply at every value — but the operator must open the firewall for that port too. Switching back to `http` STOPS THE PUBLISH but does not close the port: the publish is a `docker run` flag, so a running container keeps binding it until it is RECREATED (next deploy, or POST /api/apps/<slug>/restart/<env>, which does stop+start). Until then the port stays reachable and unauthenticated, so AppCrane keeps it RESERVED to this app — no other app can be given it, and it goes back in the pool automatically when the container comes back without the publish. Recreate the container to actually close a port, and do not report an exposure as revoked before that; the app reports the number under `pending_port_release` in the meantime. Do NOT treat the host firewall as a second lock holding this shut: on a Linux host Docker's publish is a DNAT rule evaluated in FORWARD that never traverses INPUT, so a plain `ufw deny <port>` does NOT block it — filter in DOCKER-USER or upstream. This host also sits behind SDP, so the boundary that exists is the perimeter: publishing makes the port reachable by everything inside it, not by the internet and not by nobody.",
912
+ "inputSchema": {
913
+ "type": "object",
914
+ "properties": {
915
+ "slug": {
916
+ "type": "string",
917
+ "description": "App slug."
918
+ },
919
+ "ingress_type": {
920
+ "type": "string",
921
+ "enum": [
922
+ "http",
923
+ "tcp",
924
+ "dual"
925
+ ],
926
+ "description": "http = through Caddy (default). tcp = the container port is published on the host, with no AppCrane authentication in front of it. dual = both — the HTTP control plane stays behind Caddy on container port 3000, and a separate data_plane_port in the same container is published raw alongside it."
927
+ },
928
+ "public_port": {
929
+ "type": "integer",
930
+ "minimum": 1024,
931
+ "maximum": 65535,
932
+ "description": "HOST port to publish. Only valid with ingress_type='tcp' or 'dual'. Omit to keep the port the app already holds, or to have one allocated from 31000-31999; name one explicitly and it may be anything in 1024-65535, which is what makes a client fleet already pinned to e.g. 8080 expressible. A port is stored, never derived from the app's slot, so it survives redeploys and renames — clients pinned to it keep working. Two apps cannot hold the same host port."
933
+ },
934
+ "sandbox_public_port": {
935
+ "type": [
936
+ "integer",
937
+ "null"
938
+ ],
939
+ "minimum": 1024,
940
+ "maximum": 65535,
941
+ "description": "HOST port to publish for the SANDBOX container, so a raw data plane can be exercised before it goes live. Opt-in and independent of public_port — omit it and sandbox publishes nothing, exactly as before. Pass null to drop it. Must not equal any port any other app holds in EITHER environment; the registry enforces that. Only valid with ingress_type='tcp' or 'dual'. SECURITY: this is a SECOND unauthenticated door, on the container running your least-reviewed code — it has no forward_auth, no identity headers, no audit and no TLS from AppCrane, and behind SDP it is reachable by everything inside the perimeter."
942
+ },
943
+ "data_plane_port": {
944
+ "type": [
945
+ "integer",
946
+ "null"
947
+ ],
948
+ "minimum": 1024,
949
+ "maximum": 65535,
950
+ "description": "CONTAINER port the raw publish targets, 1024-65535. REQUIRED with ingress_type='dual'. On any other type the only accepted value is null, which DROPS a data plane the app still has pinned — required to flip a dual app to 'tcp', since that publishes container port 3000 instead. Must NOT be 3000: that is the app's HTTP control plane, the port Caddy proxies to, and publishing it raw would expose the ordinary HTTP origin with no TLS, no forward_auth, no identity headers and no request audit. Give the data plane its own listener on another port inside the container. Unlike public_port this is not globally unique — container network namespaces are separate, so two apps may each use the same container-side port."
951
+ }
952
+ },
953
+ "required": [
954
+ "slug",
955
+ "ingress_type"
956
+ ],
957
+ "additionalProperties": false
958
+ }
959
+ },
960
+ {
961
+ "name": "appcrane_list_app_members",
962
+ "description": "List every user who has access to an app, with their per-app role (owner / admin / user / viewer / none). Use this before granting or revoking to see who is already in. Returns email + name + role for each member. App-admin or owner of the app required (or global admin / platform_admin).",
963
+ "inputSchema": {
964
+ "type": "object",
965
+ "properties": {
966
+ "slug": {
967
+ "type": "string"
968
+ }
969
+ },
970
+ "required": [
971
+ "slug"
972
+ ],
973
+ "additionalProperties": false
974
+ }
975
+ },
976
+ {
977
+ "name": "appcrane_grant_app_access",
978
+ "description": "Grant a user access to an app at a specific per-app role. `user` accepts a numeric user id, an email, or a username — first match wins. role defaults to \"user\". Idempotent: existing rows are upgraded/downgraded to the new role. App-admin or owner of the app required (or global admin).",
979
+ "inputSchema": {
980
+ "type": "object",
981
+ "properties": {
982
+ "slug": {
983
+ "type": "string"
984
+ },
985
+ "user": {
986
+ "type": "string",
987
+ "description": "User id (numeric string), email, or username"
988
+ },
989
+ "role": {
990
+ "type": "string",
991
+ "enum": [
992
+ "user",
993
+ "admin",
994
+ "owner"
995
+ ],
996
+ "default": "user"
997
+ }
998
+ },
999
+ "required": [
1000
+ "slug",
1001
+ "user"
1002
+ ],
1003
+ "additionalProperties": false
1004
+ }
1005
+ },
1006
+ {
1007
+ "name": "appcrane_revoke_app_access",
1008
+ "description": "Remove a user's access from an app entirely. Idempotent: returns ok even if the user had no access. App-admin or owner of the app required (or global admin). Refuses to remove the only remaining owner.",
1009
+ "inputSchema": {
1010
+ "type": "object",
1011
+ "properties": {
1012
+ "slug": {
1013
+ "type": "string"
1014
+ },
1015
+ "user": {
1016
+ "type": "string",
1017
+ "description": "User id, email, or username"
1018
+ }
1019
+ },
1020
+ "required": [
1021
+ "slug",
1022
+ "user"
1023
+ ],
1024
+ "additionalProperties": false
1025
+ }
1026
+ },
1027
+ {
1028
+ "name": "appcrane_list_app_roles",
1029
+ "description": "List the roles an app defines FOR ITSELF (approver, auditor, reviewer — whatever that app invented). Any member sees the roles and how many hold each; an owner or admin also gets `members`, the roster of who holds what. These are NOT AppCrane permissions: they grant nothing on the platform and are only handed to the app, in the X-AppCrane-App-Roles request header and in /api/me's app_roles array, for the app's own code to enforce. For AppCrane's own per-app tier — owner/admin/user/viewer, i.e. who may deploy, read env vars, or delete the app — use appcrane_list_app_members instead. Call this before creating a role (to avoid duplicating one) or before setting a user's roles (to see the valid keys). Requires being assigned to the app.",
1030
+ "inputSchema": {
1031
+ "type": "object",
1032
+ "properties": {
1033
+ "slug": {
1034
+ "type": "string"
1035
+ }
1036
+ },
1037
+ "required": [
1038
+ "slug"
1039
+ ],
1040
+ "additionalProperties": false
1041
+ }
1042
+ },
1043
+ {
1044
+ "name": "appcrane_create_app_role",
1045
+ "description": "Define a new role for an app to enforce itself — the vocabulary side of app-defined roles. This does NOT grant AppCrane privileges of any kind, and it does NOT give anyone the role: use appcrane_set_user_app_roles for that. To change who may deploy / read env / delete, you want appcrane_grant_app_access, not this tool. key is what the app's code compares against and is immutable once created; it must match /^[a-z][a-z0-9_-]{0,31}$/ and may not be one of the AppCrane-reserved words (owner, admin, user, viewer, none, platform_admin). label is the human name shown in the dashboard. An app may define at most 16 roles. Owner or admin of the app required.",
1046
+ "inputSchema": {
1047
+ "type": "object",
1048
+ "properties": {
1049
+ "slug": {
1050
+ "type": "string"
1051
+ },
1052
+ "key": {
1053
+ "type": "string",
1054
+ "description": "Machine key the app matches on, e.g. \"approver\". Lowercase, immutable."
1055
+ },
1056
+ "label": {
1057
+ "type": "string",
1058
+ "description": "Human-readable name, e.g. \"Budget approver\""
1059
+ },
1060
+ "description": {
1061
+ "type": "string",
1062
+ "description": "Optional. What the app lets this role do."
1063
+ }
1064
+ },
1065
+ "required": [
1066
+ "slug",
1067
+ "key",
1068
+ "label"
1069
+ ],
1070
+ "additionalProperties": false
1071
+ }
1072
+ },
1073
+ {
1074
+ "name": "appcrane_set_user_app_roles",
1075
+ "description": "Set which app-defined roles a user holds on one app. Replaces their whole set: keys omitted from the list are removed, and keys: [] clears every role they hold. This changes only what the app itself enforces — it does NOT change the user's AppCrane per-app tier, so it can neither grant nor remove deploy / env / delete power. Use appcrane_grant_app_access for that. Every key must already be defined on this app (appcrane_create_app_role) and the user must already have access to the app (appcrane_grant_app_access) — a role on a non-member is unenforceable, since the app never sees them. The result is what the app receives in X-AppCrane-App-Roles on the user's next request. Owner or admin of the app required.",
1076
+ "inputSchema": {
1077
+ "type": "object",
1078
+ "properties": {
1079
+ "slug": {
1080
+ "type": "string"
1081
+ },
1082
+ "user": {
1083
+ "type": "string",
1084
+ "description": "User id (numeric string), email, or username"
1085
+ },
1086
+ "keys": {
1087
+ "type": "array",
1088
+ "items": {
1089
+ "type": "string"
1090
+ },
1091
+ "description": "The COMPLETE set of app-defined role keys this user should hold. [] removes all of them."
1092
+ }
1093
+ },
1094
+ "required": [
1095
+ "slug",
1096
+ "user",
1097
+ "keys"
1098
+ ],
1099
+ "additionalProperties": false
1100
+ }
1101
+ },
1102
+ {
1103
+ "name": "appcrane_list_access_requests",
1104
+ "description": "List pending access requests — enhancement_requests rows whose message starts with \"Access request for app …\" (the portal's Request-access button posts these). With slug, scopes to one app; without, returns access requests across every app the caller can administer. App-admin / owner / global admin required.",
1105
+ "inputSchema": {
1106
+ "type": "object",
1107
+ "properties": {
1108
+ "slug": {
1109
+ "type": "string",
1110
+ "description": "Optional. Limit to one app."
1111
+ }
1112
+ },
1113
+ "additionalProperties": false
1114
+ }
1115
+ },
1116
+ {
1117
+ "name": "appcrane_approve_access_request",
1118
+ "description": "Approve a pending access request: grants the requester access to the app at `role` (default \"user\") and marks the enhancement_request as done. Verifies the request is actually an access request before acting. App-admin / owner / global admin required.",
1119
+ "inputSchema": {
1120
+ "type": "object",
1121
+ "properties": {
1122
+ "request_id": {
1123
+ "type": "integer",
1124
+ "description": "enhancement_requests.id from appcrane_list_access_requests"
1125
+ },
1126
+ "role": {
1127
+ "type": "string",
1128
+ "enum": [
1129
+ "user",
1130
+ "admin",
1131
+ "owner"
1132
+ ],
1133
+ "default": "user"
1134
+ }
1135
+ },
1136
+ "required": [
1137
+ "request_id"
1138
+ ],
1139
+ "additionalProperties": false
1140
+ }
1141
+ },
1142
+ {
1143
+ "name": "appcrane_deny_access_request",
1144
+ "description": "Deny a pending access request: marks the enhancement_request as done WITHOUT granting access. Optionally appends a reason to the original message so the requester (and the audit trail) sees why. App-admin / owner / global admin required.",
1145
+ "inputSchema": {
1146
+ "type": "object",
1147
+ "properties": {
1148
+ "request_id": {
1149
+ "type": "integer"
1150
+ },
1151
+ "reason": {
1152
+ "type": "string",
1153
+ "description": "Optional. Appended to the request message."
1154
+ }
1155
+ },
1156
+ "required": [
1157
+ "request_id"
1158
+ ],
1159
+ "additionalProperties": false
1160
+ }
1161
+ },
1162
+ {
1163
+ "name": "appcrane_set_app_icon",
1164
+ "description": "Set the tile icon for an app (shown on the Dashboard, the Launcher cards, the Manage table, and the frame topbar). Accepts a base64-encoded image in PNG / SVG / WEBP / JPEG / GIF. For repo-tracked icons prefer committing public/icon.png to the repo — AppCrane picks it up automatically on each deploy. Use this MCP tool when the icon needs to change without a redeploy, or when the source isn't in the repo. Replaces any existing icon. App-admin or owner required (or global admin).",
1165
+ "inputSchema": {
1166
+ "type": "object",
1167
+ "properties": {
1168
+ "slug": {
1169
+ "type": "string",
1170
+ "description": "App slug."
1171
+ },
1172
+ "format": {
1173
+ "type": "string",
1174
+ "enum": [
1175
+ "png",
1176
+ "svg",
1177
+ "webp",
1178
+ "jpg",
1179
+ "jpeg",
1180
+ "gif"
1181
+ ],
1182
+ "description": "Image format. Determines the on-disk file extension (icon.<format>)."
1183
+ },
1184
+ "base64": {
1185
+ "type": "string",
1186
+ "description": "Base64-encoded image payload. May or may not include the data URL prefix (data:image/png;base64,…) — both work. Max 500 KB decoded."
1187
+ }
1188
+ },
1189
+ "required": [
1190
+ "slug",
1191
+ "format",
1192
+ "base64"
1193
+ ],
1194
+ "additionalProperties": false
1195
+ }
1196
+ },
1197
+ {
1198
+ "name": "appcrane_get_guide",
1199
+ "description": "Fetch the latest AppCrane playbook on a given topic. Use this at the START of any non-trivial workflow so you operate on the current authoritative guidance, not on whatever you remember from a past session. Topics: \"onboarding\" = the full new-app onboarding playbook (paths a/b/c/d, health-endpoint contract, common pitfalls). \"operations\" = the comprehensive agent operations guide (deploy, env, logs, rollback, every appcrane_* tool). \"email\" = how a hosted app sends email through AppCrane (the /api/service/email endpoint, env vars, recipient rules). Topic defaults to \"onboarding\" if omitted. Returns markdown.",
1200
+ "inputSchema": {
1201
+ "type": "object",
1202
+ "properties": {
1203
+ "topic": {
1204
+ "type": "string",
1205
+ "enum": [
1206
+ "onboarding",
1207
+ "operations",
1208
+ "email"
1209
+ ],
1210
+ "description": "Which guide to fetch. Default: onboarding."
1211
+ }
1212
+ },
1213
+ "additionalProperties": false
1214
+ }
1215
+ },
1216
+ {
1217
+ "name": "appcrane_create_managed_app",
1218
+ "description": "Create a new app using AppCrane's GitHub service-account — the platform creates a repo on the configured org/user, owns it, and the agent works against it through github_* tools without the end user ever needing their own PAT. Use this when the user does not have a GitHub account or does not want to deal with GitHub at all. Requires the platform admin to have configured the service-account in Settings → GitHub. Returns the same shape as appcrane_create_app, plus the auto-created repo metadata. IDEMPOTENT RECOVERY: if the slug already exists as a managed app but its AMC_ repo was never created (a half-created app from an earlier failure — push then returns REPO_NOT_FOUND), calling this again re-provisions the missing repo and returns { repaired: true } instead of erroring. So if a create attempt half-failed, just call it again with the same slug. Owner-or-admin to repair an existing one.",
1219
+ "inputSchema": {
1220
+ "type": "object",
1221
+ "properties": {
1222
+ "name": {
1223
+ "type": "string",
1224
+ "description": "Display name (human-readable)"
1225
+ },
1226
+ "slug": {
1227
+ "type": "string",
1228
+ "description": "URL slug, lowercase-alphanumeric-with-dashes. Becomes the repo name."
1229
+ },
1230
+ "description": {
1231
+ "type": "string",
1232
+ "description": "Optional. Used as both app description and repo description."
1233
+ },
1234
+ "branch": {
1235
+ "type": "string",
1236
+ "description": "Default branch for the new repo. Defaults to \"main\"."
1237
+ },
1238
+ "domain": {
1239
+ "type": "string",
1240
+ "description": "Optional custom domain."
1241
+ },
1242
+ "max_ram_mb": {
1243
+ "type": "number",
1244
+ "description": "Per-container memory cap. Default: 512."
1245
+ },
1246
+ "max_cpu_percent": {
1247
+ "type": "number",
1248
+ "description": "Per-container CPU cap. Default: 50."
1249
+ }
1250
+ },
1251
+ "required": [
1252
+ "name",
1253
+ "slug"
1254
+ ],
1255
+ "additionalProperties": false
1256
+ }
1257
+ },
1258
+ {
1259
+ "name": "appcrane_push_to_managed_app",
1260
+ "description": "Push a batch of files to a managed app's AMC_<slug> repo, authenticated server-side via AppCrane's service-account credential. Use this — NOT github_push_files — for managed apps, because github_* tools authenticate with the caller's personal PAT, which has zero access to the service account's repos. Multiple files become a single commit. files: [{ path, content, encoding? }] where encoding defaults to \"utf-8\" (use \"base64\" for binaries like icons). Requires the app to already exist via appcrane_create_managed_app. v2.7.22: response now includes per-file `sha256` (hex) and decoded `bytes` length so you can verify integrity — compute the SHA-256 of the bytes you sent, compare to the server's echo, and fail loudly if they differ. Essential for binary files where inline-string truncation or trailing-byte issues would otherwise produce a silently-broken commit. v2.10.7: for a large CODE file, do NOT inline it — upload the bytes over HTTP and commit by token. (1) `curl -F file=@big.js -H \"X-API-Key: <your dhk_mcp_ key>\" https://<host>/api/files/staged` returns { token, sha256, size_bytes }. (2) Pass that file as { path, staged_token } instead of { path, content }. The server reads the staged bytes and commits them verbatim, so 100+ KB sources push reliably without the model having to emit the content (which is where inline truncation comes from). Per file, provide exactly one of content or staged_token. Staged tokens are owner-scoped and expiring.",
1261
+ "inputSchema": {
1262
+ "type": "object",
1263
+ "properties": {
1264
+ "slug": {
1265
+ "type": "string",
1266
+ "description": "Managed app slug. Repo name resolved as AMC_<slug>."
1267
+ },
1268
+ "files": {
1269
+ "type": "array",
1270
+ "minItems": 1,
1271
+ "maxItems": 200,
1272
+ "items": {
1273
+ "type": "object",
1274
+ "properties": {
1275
+ "path": {
1276
+ "type": "string",
1277
+ "description": "Repo-relative path (no leading slash, no ..)"
1278
+ },
1279
+ "content": {
1280
+ "type": "string",
1281
+ "description": "Inline file content. For binary, base64-encode and set encoding=\"base64\". Omit when using staged_token."
1282
+ },
1283
+ "encoding": {
1284
+ "type": "string",
1285
+ "enum": [
1286
+ "utf-8",
1287
+ "base64"
1288
+ ],
1289
+ "description": "Defaults to utf-8. Ignored when staged_token is used (staged bytes are committed as-is)."
1290
+ },
1291
+ "staged_token": {
1292
+ "type": "string",
1293
+ "description": "Token from POST /api/files/staged. Commits the uploaded bytes verbatim — use instead of content for large code files. Exactly one of content / staged_token per file."
1294
+ }
1295
+ },
1296
+ "required": [
1297
+ "path"
1298
+ ],
1299
+ "additionalProperties": false
1300
+ }
1301
+ },
1302
+ "message": {
1303
+ "type": "string",
1304
+ "description": "Commit message. Defaults to \"chore: scaffolding for <slug>\"."
1305
+ },
1306
+ "branch": {
1307
+ "type": "string",
1308
+ "description": "Target branch. Defaults to the repo's default branch (usually \"main\")."
1309
+ }
1310
+ },
1311
+ "required": [
1312
+ "slug",
1313
+ "files"
1314
+ ],
1315
+ "additionalProperties": false
1316
+ }
1317
+ },
1318
+ {
1319
+ "name": "appcrane_managed_push_chunk",
1320
+ "description": "Stage ONE part of a large file for a managed app, entirely over MCP (no HTTP upload). Use this + appcrane_managed_assemble when a file is too large to emit reliably inline via appcrane_push_to_managed_app (roughly >64 KB of code). Workflow: pick an opaque `session` id (any unique string, e.g. \"app.tsx-1\"), split the file into N parts, and call this once per part with part=1..N and of=N. The parts are held server-side keyed by (session, part); appcrane_managed_assemble then concatenates them in order, verifies the whole, and commits. Split on any boundary you like (byte or line) — assemble concatenates the decoded bytes verbatim, so the split points do not need to be newlines. Keep each part small (≤ ~48 KB of content) so inline emission stays reliable. encoding defaults to \"utf-8\"; use \"base64\" for binary. If you provide `sha256` (hex SHA-256 of THIS part's decoded bytes), the server verifies it on arrival and rejects a corrupted part immediately. Re-sending the same (session, part) overwrites it, so a failed part is safe to retry.",
1321
+ "inputSchema": {
1322
+ "type": "object",
1323
+ "properties": {
1324
+ "slug": {
1325
+ "type": "string",
1326
+ "description": "Managed app slug (repo AMC_<slug>)."
1327
+ },
1328
+ "path": {
1329
+ "type": "string",
1330
+ "description": "Repo-relative destination path (no leading slash, no \"..\"). Must be identical across every part of a session."
1331
+ },
1332
+ "session": {
1333
+ "type": "string",
1334
+ "description": "Opaque upload id grouping the parts. Any unique string; reuse the same value for every part of one file."
1335
+ },
1336
+ "part": {
1337
+ "type": "integer",
1338
+ "minimum": 1,
1339
+ "description": "1-based part number."
1340
+ },
1341
+ "of": {
1342
+ "type": "integer",
1343
+ "minimum": 1,
1344
+ "description": "Total number of parts. Must be identical across every part of a session."
1345
+ },
1346
+ "content": {
1347
+ "type": "string",
1348
+ "description": "This part's bytes, encoded per `encoding`."
1349
+ },
1350
+ "encoding": {
1351
+ "type": "string",
1352
+ "enum": [
1353
+ "utf-8",
1354
+ "base64"
1355
+ ],
1356
+ "description": "Defaults to utf-8. Use base64 for binary files."
1357
+ },
1358
+ "sha256": {
1359
+ "type": "string",
1360
+ "description": "Optional hex SHA-256 of this part's decoded bytes. If given, the server verifies it and rejects a mismatch."
1361
+ }
1362
+ },
1363
+ "required": [
1364
+ "slug",
1365
+ "path",
1366
+ "session",
1367
+ "part",
1368
+ "of",
1369
+ "content"
1370
+ ],
1371
+ "additionalProperties": false
1372
+ }
1373
+ },
1374
+ {
1375
+ "name": "appcrane_managed_assemble",
1376
+ "description": "Finish a chunked upload started with appcrane_managed_push_chunk: concatenate all parts of a `session` in order, verify the whole, and commit the assembled file to the managed app's AMC_<slug> repo as a single commit. Fails if any part is missing. If you pass `sha256` (hex SHA-256 of the ENTIRE original file's bytes), the server verifies the reassembled bytes against it and refuses to commit on mismatch — always pass it for large or binary files. On success the staged parts are deleted. Returns the commit sha plus the committed file's sha256 and byte length.",
1377
+ "inputSchema": {
1378
+ "type": "object",
1379
+ "properties": {
1380
+ "slug": {
1381
+ "type": "string",
1382
+ "description": "Managed app slug (repo AMC_<slug>)."
1383
+ },
1384
+ "session": {
1385
+ "type": "string",
1386
+ "description": "The upload id you used for appcrane_managed_push_chunk."
1387
+ },
1388
+ "path": {
1389
+ "type": "string",
1390
+ "description": "The destination path; must match what the parts were staged with."
1391
+ },
1392
+ "sha256": {
1393
+ "type": "string",
1394
+ "description": "Optional hex SHA-256 of the whole original file. If given, the reassembled bytes are verified against it before committing."
1395
+ },
1396
+ "message": {
1397
+ "type": "string",
1398
+ "description": "Commit message. Defaults to \"chore: update <path>\"."
1399
+ },
1400
+ "branch": {
1401
+ "type": "string",
1402
+ "description": "Target branch. Defaults to the app's branch / repo default."
1403
+ }
1404
+ },
1405
+ "required": [
1406
+ "slug",
1407
+ "session",
1408
+ "path"
1409
+ ],
1410
+ "additionalProperties": false
1411
+ }
1412
+ },
1413
+ {
1414
+ "name": "appcrane_managed_patch",
1415
+ "description": "Edit an existing text file in a managed app's AMC_<slug> repo by applying a unified diff, entirely over MCP — you emit only the changed hunks, not the whole file. Ideal for small edits to a large file (avoids re-emitting the whole thing, which is where inline truncation comes from). The server fetches the current file, applies your `unified_diff`, and commits the result as a single commit. The diff must be a standard unified diff (as from `git diff` / `diff -u`): `@@ -old,len +new,len @@` hunk headers, lines prefixed with \" \" (context), \"-\" (remove), \"+\" (add). Include a few context lines around each change. Hunks are matched by CONTENT (not just line numbers), so small line drift is tolerated — but if a hunk's context does not match the current file, the whole patch is rejected and nothing is committed (re-read the file and regenerate the diff). Only single-file diffs are supported; the target is `path`, not the diff's ---/+++ headers.",
1416
+ "inputSchema": {
1417
+ "type": "object",
1418
+ "properties": {
1419
+ "slug": {
1420
+ "type": "string",
1421
+ "description": "Managed app slug (repo AMC_<slug>)."
1422
+ },
1423
+ "path": {
1424
+ "type": "string",
1425
+ "description": "Repo-relative path of the file to patch (no leading slash, no \"..\")."
1426
+ },
1427
+ "unified_diff": {
1428
+ "type": "string",
1429
+ "description": "A standard unified diff to apply to the current contents of `path`."
1430
+ },
1431
+ "message": {
1432
+ "type": "string",
1433
+ "description": "Commit message. Defaults to \"chore: patch <path>\"."
1434
+ },
1435
+ "branch": {
1436
+ "type": "string",
1437
+ "description": "Target branch. Defaults to the app's branch / repo default."
1438
+ }
1439
+ },
1440
+ "required": [
1441
+ "slug",
1442
+ "path",
1443
+ "unified_diff"
1444
+ ],
1445
+ "additionalProperties": false
1446
+ }
1447
+ },
1448
+ {
1449
+ "name": "appcrane_set_secret",
1450
+ "description": "Set or update a secret (an encrypted environment variable injected into the app). Encrypted at rest; only the running app process can read the plaintext. Defaults to sandbox; require explicit stage=\"production\" only when the user asks. App-admin or AppCrane admin only. Respects the caller's mcp_app_scope.",
1451
+ "inputSchema": {
1452
+ "type": "object",
1453
+ "properties": {
1454
+ "slug": {
1455
+ "type": "string"
1456
+ },
1457
+ "stage": {
1458
+ "type": "string",
1459
+ "enum": [
1460
+ "sandbox",
1461
+ "production"
1462
+ ],
1463
+ "default": "sandbox",
1464
+ "description": "Target stage (legacy alias: env)."
1465
+ },
1466
+ "key": {
1467
+ "type": "string",
1468
+ "description": "Env var name. Letters, digits, underscores; must not start with a digit."
1469
+ },
1470
+ "value": {
1471
+ "type": "string",
1472
+ "description": "The value to store (will be encrypted server-side)."
1473
+ }
1474
+ },
1475
+ "required": [
1476
+ "slug",
1477
+ "key",
1478
+ "value"
1479
+ ],
1480
+ "additionalProperties": false
1481
+ }
1482
+ },
1483
+ {
1484
+ "name": "appcrane_cp",
1485
+ "description": "Copy/upload a file straight into the app's persistent /data volume on the host (aliases: appcrane_upload, appcrane_set_data_blob) — single hop, no container round-trip, no GitHub round-trip, no inline size ceiling. The bytes land at /data/apps/<slug>/<env>/shared/data/<path>, which is the SAME path the running container sees mounted as /data/<path>. Right tool for multi-MB datasets, large fixtures, or anything where appcrane_push_to_managed_app's tool-arg ceiling would force chunking. Returns the SHA-256 + byte count of what was stored so the caller can verify integrity. App-admin or owner of the app required. NEVER returns secrets in the response. Path must be repo-relative, no `..`, no leading slash.",
1486
+ "inputSchema": {
1487
+ "type": "object",
1488
+ "properties": {
1489
+ "slug": {
1490
+ "type": "string",
1491
+ "description": "App slug."
1492
+ },
1493
+ "stage": {
1494
+ "type": "string",
1495
+ "enum": [
1496
+ "sandbox",
1497
+ "production"
1498
+ ],
1499
+ "default": "sandbox",
1500
+ "description": "Which env's /data volume to write to. Target stage (legacy alias: env)."
1501
+ },
1502
+ "path": {
1503
+ "type": "string",
1504
+ "description": "Path within /data, e.g. \"datasets/threats.json\" or \"cache/build.tar.gz\". No leading slash, no \"..\"."
1505
+ },
1506
+ "content": {
1507
+ "type": "string",
1508
+ "description": "The data to write. utf-8 string or base64-encoded bytes depending on encoding."
1509
+ },
1510
+ "encoding": {
1511
+ "type": "string",
1512
+ "enum": [
1513
+ "utf-8",
1514
+ "base64"
1515
+ ],
1516
+ "default": "utf-8",
1517
+ "description": "Defaults to utf-8. Use base64 for binary blobs."
1518
+ }
1519
+ },
1520
+ "required": [
1521
+ "slug",
1522
+ "path",
1523
+ "content"
1524
+ ],
1525
+ "additionalProperties": false
1526
+ }
1527
+ },
1528
+ {
1529
+ "name": "appcrane_list_cron",
1530
+ "description": "List the scheduled jobs declared in an app's deployhub.json `cron` array (after the most recent deploy). Each entry includes the cron schedule, the command, when it last ran, the exit code, and the tail of the last run's stdout/stderr. Use to verify a job was registered, debug a missing run, or read the recent log. App-admin or owner.",
1531
+ "inputSchema": {
1532
+ "type": "object",
1533
+ "properties": {
1534
+ "slug": {
1535
+ "type": "string"
1536
+ },
1537
+ "stage": {
1538
+ "type": "string",
1539
+ "enum": [
1540
+ "sandbox",
1541
+ "production"
1542
+ ],
1543
+ "description": "Optional — omit to list both envs. Target stage (legacy alias: env)."
1544
+ }
1545
+ },
1546
+ "required": [
1547
+ "slug"
1548
+ ],
1549
+ "additionalProperties": false
1550
+ }
1551
+ },
1552
+ {
1553
+ "name": "appcrane_run_cron_now",
1554
+ "description": "Trigger a scheduled cron job RIGHT NOW, regardless of its schedule. Useful for \"I want to test my daily rebuild without waiting until midnight\" or \"rerun yesterday's failed job.\" Runs the same `docker exec` the tick loop would, against the app's container; updates last_run_at / last_exit_code / last_log just like a scheduled run. Returns the exit code and last-log tail. App-admin or owner. Idempotent: if the job is already running (mutex held), reports it and skips rather than overlapping.",
1555
+ "inputSchema": {
1556
+ "type": "object",
1557
+ "properties": {
1558
+ "slug": {
1559
+ "type": "string"
1560
+ },
1561
+ "stage": {
1562
+ "type": "string",
1563
+ "enum": [
1564
+ "sandbox",
1565
+ "production"
1566
+ ],
1567
+ "default": "sandbox",
1568
+ "description": "Target stage (legacy alias: env)."
1569
+ },
1570
+ "name": {
1571
+ "type": "string",
1572
+ "description": "Job name from deployhub.json `cron[].name`."
1573
+ }
1574
+ },
1575
+ "required": [
1576
+ "slug",
1577
+ "name"
1578
+ ],
1579
+ "additionalProperties": false
1580
+ }
1581
+ },
1582
+ {
1583
+ "name": "appcrane_check_resource_limits",
1584
+ "description": "Which containers are NOT running with the CPU/RAM limits AppCrane has configured for them? Compares every app row against the limits actually in force on its container and reports only the mismatches. `--memory` and `--cpus` are `docker run` flags, so changing a limit rewrites the database and nothing else until the container is RECREATED — a container created before the limit was set keeps running without it, and every other AppCrane surface reports the CONFIGURED number, so the two are indistinguishable without this. `memory state=not_applied` means NO limit at all: that container can take the whole host, and on a host with no swap that ends as a global OOM kill of whatever the kernel judges largest. Answers `applied: null` (unknown) rather than guessing when Docker cannot be read. ADMIN ONLY.",
1585
+ "inputSchema": {
1586
+ "type": "object",
1587
+ "properties": {
1588
+ "slug": {
1589
+ "type": "string",
1590
+ "description": "Check one app instead of the whole fleet."
1591
+ },
1592
+ "include_ok": {
1593
+ "type": "boolean",
1594
+ "default": false,
1595
+ "description": "Also list containers whose limits ARE applied. Default false — the point is the exceptions."
1596
+ }
1597
+ },
1598
+ "additionalProperties": false
1599
+ }
1600
+ },
1601
+ {
1602
+ "name": "appcrane_get_backup_status",
1603
+ "description": "Is off-site backup actually working? Reports the scheduled S3/R2 backup config together with a verdict — `configured`, `enabled`, `healthy`, when it last ran and what is missing — so \"are we backed up\" is one call rather than an inference from raw settings. The backup covers the SQLite database (apps, users, settings, encrypted env vars), .env, icons and appdata, uploaded nightly as one zip. NEVER returns the secret access key; `has_secret` reports only whether one is stored. Read the `summary` first: a config can be fully populated and still not be running (enabled=false), and it can be enabled and failing every night (see last_error). PLATFORM ADMIN ONLY.",
1604
+ "inputSchema": {
1605
+ "type": "object",
1606
+ "properties": {},
1607
+ "additionalProperties": false
1608
+ }
1609
+ },
1610
+ {
1611
+ "name": "appcrane_set_backup_config",
1612
+ "description": "Configure the scheduled off-site (S3 / S3-compatible, e.g. Cloudflare R2) backup. Every field is optional — only what you pass is changed. Enabling is REFUSED unless bucket, access_key_id and a stored secret are all present, because an enabled-but-unconfigured backup fails silently every night while every status surface reads \"enabled\", which is worse than being plainly off. SECURITY: `secret_access_key` is write-only — AppCrane encrypts it and never returns it — but passing it here means the plaintext value travels through this conversation and whatever logs it. Prefer Settings → Backup in the dashboard for the secret itself, and use this tool for the rest. PLATFORM ADMIN ONLY: this names the destination a copy of every secret on the platform is written to, so pointing it at the wrong bucket is an exfiltration path, not a misconfiguration.",
1613
+ "inputSchema": {
1614
+ "type": "object",
1615
+ "properties": {
1616
+ "enabled": {
1617
+ "type": "boolean",
1618
+ "description": "Turn the nightly schedule on or off. Refused with enabled=true unless bucket, access_key_id and a stored secret all exist."
1619
+ },
1620
+ "bucket": {
1621
+ "type": "string",
1622
+ "description": "Destination bucket name."
1623
+ },
1624
+ "region": {
1625
+ "type": "string",
1626
+ "description": "Region, e.g. us-east-1. Defaults to us-east-1."
1627
+ },
1628
+ "prefix": {
1629
+ "type": "string",
1630
+ "description": "Key prefix inside the bucket, e.g. \"appcrane/\". Optional."
1631
+ },
1632
+ "endpoint": {
1633
+ "type": "string",
1634
+ "description": "Custom S3 endpoint for a non-AWS provider (Cloudflare R2, MinIO). Leave empty for AWS."
1635
+ },
1636
+ "access_key_id": {
1637
+ "type": "string",
1638
+ "description": "Access key id. Not a secret on its own; stored in the clear."
1639
+ },
1640
+ "secret_access_key": {
1641
+ "type": "string",
1642
+ "description": "Secret access key. Write-only: encrypted at rest, never returned by any read surface. NOTE: passing it here puts the plaintext in this conversation — the dashboard is the better place for it."
1643
+ },
1644
+ "hour": {
1645
+ "type": "integer",
1646
+ "minimum": 0,
1647
+ "maximum": 23,
1648
+ "description": "Hour of day (server local time) to run. Default 3."
1649
+ }
1650
+ },
1651
+ "additionalProperties": false
1652
+ }
1653
+ },
1654
+ {
1655
+ "name": "appcrane_run_backup_now",
1656
+ "description": "Run the off-site backup immediately and report what was uploaded. Use this to PROVE a new configuration works instead of waiting for the nightly run to fail quietly — it exercises the real credentials, the real bucket and the real upload path, and records the result in last_run / last_error exactly as the scheduled job does. Works whether or not the schedule is enabled, so a configuration can be verified before turning it on. Uploads a zip of the SQLite database, .env, icons and appdata. PLATFORM ADMIN ONLY.",
1657
+ "inputSchema": {
1658
+ "type": "object",
1659
+ "properties": {},
1660
+ "additionalProperties": false
1661
+ }
1662
+ },
1663
+ {
1664
+ "name": "appcrane_memory_budget",
1665
+ "description": "Does the sum of every app's CONFIGURED memory ceiling fit in this host's RAM? Adds up the per-container `--memory` limits AppCrane has on file and compares the total against total host memory. THESE ARE CONFIGURED CEILINGS, NOT MEASURED USAGE: a report of \"25 GB committed on a 7.6 GB host\" does NOT mean the host is using 25 GB, and must never be relayed as one — it means the limits promise more than the host can deliver if the containers ever ask for it at once. Nothing here reads a running container; for what is actually in force on the containers use appcrane_check_resource_limits, and for live consumption read the host. Over-commitment is normal and is not by itself a fault (containers idle far below their ceilings) — it matters because it means there is no headroom guarantee, so a correlated event such as a post-reboot cold start, when every container loads at once, is resolved by the kernel's global OOM killer. Counts both stages of every app, which is exactly what a cold start brings up. ADMIN ONLY.",
1666
+ "inputSchema": {
1667
+ "type": "object",
1668
+ "properties": {},
1669
+ "additionalProperties": false
1670
+ }
1671
+ },
1672
+ {
1673
+ "name": "appcrane_scan_report",
1674
+ "description": "Which hosted apps have known-vulnerable dependencies? Reports the recorded CVE scan state for the whole fleet, or for one app with `slug`. REPORT ONLY: this scan has never blocked a deploy and cannot — the apps belong to other teams who did not choose the control, so findings are recorded and mailed and the deploy proceeds either way. Never relay a finding as a deploy failure. READ `status` BEFORE READING COUNTS. It is four-valued: `ok` (scanned, nothing found) and `findings` (scanned, something found) are results; `skipped` (no lockfile AppCrane can read) and `error` (OSV unreachable, unparseable lockfile) mean the app was NOT SCANNED, as does having no scan row at all. Those two carry no findings for the same reason an unopened box is empty, and an agent that reports such an app as clean has stated the opposite of what is known — \"no vulnerabilities found\" is only ever true of an app whose status is `ok`. `assurance` (none / partial / complete), `unscanned_count` and `unscanned_by_status` say how much of the fleet the numbers actually cover; read them before the findings. EVERY FINDING CARRIES `ecosystem` AND `fixed` beside `name`, `version` and `ids`. `fixed` is the version that resolves those advisories, or null when OSV PUBLISHED NO FIXED VERSION — a null means there is nothing to upgrade to yet, NEVER that no fix is needed and never that AppCrane did not look, so a null-`fixed` finding is not a harmless one. `manifests_scanned` says WHICH manifests were actually read, because coverage is per manifest and not per app: one scan row reads ONE manifest — the ecosystem named on it — so an app whose Go service was never read appears here beside its scanned npm frontend with an empty findings list, and that emptiness is evidence about the frontend only. Scans run at deploy AND daily, and the daily run is the one that matters, because it catches an advisory published against code that was already deployed and has not changed since. ADMIN ONLY.",
1675
+ "inputSchema": {
1676
+ "type": "object",
1677
+ "properties": {
1678
+ "slug": {
1679
+ "type": "string",
1680
+ "description": "Report one app (both stages) instead of the whole fleet."
1681
+ }
1682
+ },
1683
+ "additionalProperties": false
1684
+ }
1685
+ },
1686
+ {
1687
+ "name": "appcrane_scan_app",
1688
+ "description": "Scan one app's dependencies against OSV right now and record the result, instead of waiting for its next deploy or the nightly run. Use it after fixing a lockfile to confirm a finding is gone, or on an app whose last result was `skipped` or `error` to find out what it actually contains. REPORT ONLY — it records a row and feeds the daily digest; it never blocks, fails or rolls back anything, and running it cannot disturb the app. It does not throw on a failed scan either: an unreachable OSV or a missing lockfile comes back as `ok: false` with status `error` or `skipped`, meaning the app was NOT scanned and is NOT known to be clean. `ok: true` with status `findings` is the opposite case — the scan worked and found something. Reads the LIVE release through the same `current` symlink the running container was built from. Defaults to the PRODUCTION stage: most AppCrane tools default to sandbox, but the code an advisory applies to is the code that is serving. ADMIN ONLY.",
1689
+ "inputSchema": {
1690
+ "type": "object",
1691
+ "properties": {
1692
+ "slug": {
1693
+ "type": "string",
1694
+ "description": "App to scan."
1695
+ },
1696
+ "stage": {
1697
+ "type": "string",
1698
+ "enum": [
1699
+ "sandbox",
1700
+ "production"
1701
+ ],
1702
+ "default": "production",
1703
+ "description": "Stage to scan. Defaults to production — that is the deployed code an advisory applies to. Target stage (legacy alias: env)."
1704
+ }
1705
+ },
1706
+ "required": [
1707
+ "slug"
1708
+ ],
1709
+ "additionalProperties": false
1710
+ }
1711
+ },
1712
+ {
1713
+ "name": "appcrane_platform_policy",
1714
+ "description": "Read or set the two platform-wide policy levers, and list the apps currently in violation. `ban_public_apps` refuses visibility=public on every write path; `mandate_security_scans` reports every app without a completed scan in the last 48h. Both default OFF, so an upgrade enforces nothing until an admin turns one on. Call with no arguments to read the current policy plus violations; pass either boolean to change it. POLICY IS NOT RETROACTIVE: turning a lever on refuses the NEXT write and REPORTS what is already in violation — it does not reach into the database and change existing apps, and the violations it lists keep working exactly as they did. That is deliberate: silently making live public apps private would break their URLs with no warning to their owners and no record of what changed, so the list exists for an admin to work through deliberately. Never describe enabling a lever as having fixed the apps it reports; nothing about them has changed. PLATFORM ADMIN ONLY.",
1715
+ "inputSchema": {
1716
+ "type": "object",
1717
+ "properties": {
1718
+ "ban_public_apps": {
1719
+ "type": "boolean",
1720
+ "description": "Refuse visibility=public everywhere. Existing public apps keep serving and are reported as violations."
1721
+ },
1722
+ "mandate_security_scans": {
1723
+ "type": "boolean",
1724
+ "description": "Report every app with no completed dependency scan in the last 48h (two missed daily runs)."
1725
+ }
1726
+ },
1727
+ "additionalProperties": false
1728
+ }
1729
+ }
1730
+ ]