@zibby/mcp-cli 0.3.5 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +272 -23
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -67,6 +67,15 @@ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
67
67
 
68
68
  const API_BASE = process.env.ZIBBY_API_URL || 'https://api-prod.zibby.app';
69
69
 
70
+ // Warm-pool quantity options — MUST stay in sync with
71
+ // backend/src/config/plans.js ADDONS.warm_pool.quantityOptions.
72
+ // The backend rejects anything outside this set with 400. We duplicate
73
+ // the constant here (rather than importing from @zibby/cli) because
74
+ // @zibby/cli has no exports map and the dev-vs-published layouts differ
75
+ // (src/config in dev, dist/config in npm). Mirror at @zibby/cli's
76
+ // src/config/warmPool.js.
77
+ const WARM_POOL_QUANTITY_OPTIONS = [1, 2, 3];
78
+
70
79
  // ── Config file helpers (matches @zibby/cli/src/config/config.js shape) ─
71
80
 
72
81
  function loadConfig() {
@@ -193,6 +202,18 @@ const server = new McpServer({
193
202
  version: '0.3.0',
194
203
  });
195
204
 
205
+ // ── Dual-register helper (product-noun migration: "workflow" → "agent") ──
206
+ // The product UI now calls deployed automations "Agents". We expose every
207
+ // workflow-related tool under BOTH its historical `*_workflow*` name AND a
208
+ // new `*_agent*` name, pointing at the SAME schema + handler. Old names keep
209
+ // working (additive alias, never removed); the agent-named ones are the new
210
+ // primary. Internal CLI verbs / API routes / package names are untouched —
211
+ // only the MCP-facing tool name + description prose change.
212
+ function toolWithAlias(canonicalName, aliasName, description, schema, handler) {
213
+ server.tool(canonicalName, description, schema, handler);
214
+ server.tool(aliasName, description, schema, handler);
215
+ }
216
+
196
217
  // ── Tool: login ─────────────────────────────────────────────────────────
197
218
  // Device-code OAuth flow. Opens the user's browser to the verification URL,
198
219
  // then polls /cli/login/poll until the user authorizes (or it times out).
@@ -327,24 +348,26 @@ server.tool(
327
348
  // ── Tool: list templates ────────────────────────────────────────────────
328
349
  // Local because @zibby/cli reads the bundled template manifest from its
329
350
  // own node_modules (no network call).
330
- server.tool(
351
+ toolWithAlias(
331
352
  'zibby_list_templates',
332
- 'List the official Zibby workflow templates available to scaffold (browser-test-automation, code-analysis, generate-test-cases, etc.). These are the same templates the marketplace deploys. Reads the manifest bundled with the local @zibby/cli — no network call.',
353
+ 'zibby_list_agent_templates',
354
+ 'List the official Zibby agent templates available to scaffold (browser-test-automation, code-analysis, generate-test-cases, etc.). These are the same templates the marketplace deploys. Reads the manifest bundled with the local @zibby/cli — no network call.',
333
355
  {},
334
356
  async () => cliResult(await runCli(['template', 'list']))
335
357
  );
336
358
 
337
359
  // ── Tool: scaffold workflow ─────────────────────────────────────────────
338
360
  // Writes files to .zibby/workflows/<name>/ in the user's cwd.
339
- server.tool(
361
+ toolWithAlias(
340
362
  'zibby_scaffold_workflow',
341
- 'Scaffold a new workflow into the current project\'s .zibby/workflows/<name>/ directory from an official template. Generates graph.mjs, nodes/, state.js, and package.json on the user\'s local disk. Use zibby_list_templates first to see options.',
363
+ 'zibby_scaffold_agent',
364
+ 'Scaffold a new agent into the current project\'s .zibby/workflows/<name>/ directory from an official template. Generates graph.mjs, nodes/, state.js, and package.json on the user\'s local disk. Use zibby_list_agent_templates first to see options.',
342
365
  {
343
- name: z.string().min(1).describe('Local workflow folder name (kebab-case)'),
366
+ name: z.string().min(1).describe('Local agent folder name (kebab-case)'),
344
367
  template: z.enum(['browser-test-automation', 'code-analysis', 'generate-test-cases'])
345
368
  .describe('Official template to scaffold from'),
346
369
  skipInstall: z.boolean().optional().default(false)
347
- .describe('Skip running `npm install` in the new workflow folder'),
370
+ .describe('Skip running `npm install` in the new agent folder'),
348
371
  },
349
372
  async ({ name, template, skipInstall }) => {
350
373
  const args = ['g', 'workflow', name, '-t', template, '--no-agent-helpers'];
@@ -355,11 +378,12 @@ server.tool(
355
378
 
356
379
  // ── Tool: validate workflow ─────────────────────────────────────────────
357
380
  // Reads local workflow files + spawns the local validator.
358
- server.tool(
381
+ toolWithAlias(
359
382
  'zibby_validate_workflow',
360
- 'Static-check a local workflow (.zibby/workflows/<name>/): graph topology, state schema, skill references. Fast (~30ms) — runs entirely locally against files on disk, no API call. Run this before deploy to catch obvious errors.',
383
+ 'zibby_validate_agent',
384
+ 'Static-check a local agent (.zibby/workflows/<name>/): graph topology, state schema, skill references. Fast (~30ms) — runs entirely locally against files on disk, no API call. Run this before deploy to catch obvious errors.',
361
385
  {
362
- name: z.string().min(1).describe('Workflow folder name under .zibby/workflows/'),
386
+ name: z.string().min(1).describe('Agent folder name under .zibby/workflows/'),
363
387
  },
364
388
  async ({ name }) => cliResult(await runCli(['workflow', 'validate', name]))
365
389
  );
@@ -368,16 +392,19 @@ server.tool(
368
392
  // Local-essential because the bundling step (zip the .zibby/workflows/<name>/
369
393
  // folder + walk its node_modules) happens on the user's machine before
370
394
  // upload. The remote MCP can't see those files.
371
- server.tool(
395
+ toolWithAlias(
372
396
  'zibby_deploy_workflow',
373
- 'Deploy a local workflow (.zibby/workflows/<name>/) to Zibby Cloud under the given project. Bundles the local workflow folder + dependencies, then uploads. Returns the workflow UUID + version on success. Use zibby_validate_workflow first to catch errors fast.',
397
+ 'zibby_deploy_agent',
398
+ 'Deploy a local agent (.zibby/workflows/<name>/) to Zibby Cloud under the given project. Bundles the local agent folder + dependencies, then uploads. Returns the agent UUID + version on success. Use zibby_validate_agent first to catch errors fast.',
374
399
  {
375
- name: z.string().min(1).describe('Local workflow folder name'),
400
+ name: z.string().min(1).describe('Local agent folder name'),
376
401
  projectId: z.string().min(1).describe('Project to deploy under (use the Zibby Remote MCP\'s zibby_list_projects to discover)'),
377
402
  force: z.boolean().optional().default(false)
378
403
  .describe('Re-deploy even if source checksum is unchanged'),
379
- warm: z.number().int().min(1).max(5).optional()
380
- .describe('Enable warm-pool execution (1-5 always-on Fargate tasks) — paid feature, skips ~60s cold start'),
404
+ warm: z.number().int().refine((n) => WARM_POOL_QUANTITY_OPTIONS.includes(n), {
405
+ message: `warm must be one of [${WARM_POOL_QUANTITY_OPTIONS.join(', ')}]`,
406
+ }).optional()
407
+ .describe(`Enable warm-pool execution (${WARM_POOL_QUANTITY_OPTIONS.join('/')} always-on runners) — paid feature, skips ~60s cold start`),
381
408
  },
382
409
  async ({ name, projectId, force, warm }) => {
383
410
  const apiKey = getProjectApiToken(projectId);
@@ -393,13 +420,14 @@ server.tool(
393
420
 
394
421
  // ── Tool: run workflow locally ──────────────────────────────────────────
395
422
  // Spawns a local node process to run the workflow against local files.
396
- server.tool(
423
+ toolWithAlias(
397
424
  'zibby_run_workflow_local',
398
- 'Run a local workflow (.zibby/workflows/<name>/) one-shot on the user\'s machine. Does NOT touch the cloud — used for debugging graph.mjs / node code before deploying. Output includes per-node state transitions.',
425
+ 'zibby_run_agent_local',
426
+ 'Run a local agent (.zibby/workflows/<name>/) one-shot on the user\'s machine. Does NOT touch the cloud — used for debugging graph.mjs / node code before deploying. Output includes per-node state transitions.',
399
427
  {
400
- name: z.string().min(1).describe('Local workflow folder name'),
428
+ name: z.string().min(1).describe('Local agent folder name'),
401
429
  input: z.record(z.string(), z.any()).optional().default({})
402
- .describe('Input params (JSON object passed to the workflow\'s entry node)'),
430
+ .describe('Input params (JSON object passed to the agent\'s entry node)'),
403
431
  },
404
432
  async ({ name, input }) => {
405
433
  const args = ['workflow', 'run', name, '--input', JSON.stringify(input || {})];
@@ -411,12 +439,13 @@ server.tool(
411
439
  // Destructive enough to require explicit user confirmation since it can
412
440
  // overwrite files in the user's working directory. The agent MUST set
413
441
  // `confirm: true` AND provide a `dest` path it has shown to the user.
414
- server.tool(
442
+ toolWithAlias(
415
443
  'zibby_download_workflow',
416
- 'Download a deployed workflow back to a local directory (e.g. to edit it then re-deploy). DESTRUCTIVE: can overwrite files in the destination directory. The agent MUST first ask the user for confirmation and the destination path, then call this with confirm=true.',
444
+ 'zibby_download_agent',
445
+ 'Download a deployed agent back to a local directory (e.g. to edit it then re-deploy). DESTRUCTIVE: can overwrite files in the destination directory. The agent MUST first ask the user for confirmation and the destination path, then call this with confirm=true.',
417
446
  {
418
- uuid: z.string().min(1).describe('Workflow UUID to download'),
419
- projectId: z.string().min(1).describe('Project the workflow lives under'),
447
+ uuid: z.string().min(1).describe('Agent UUID to download'),
448
+ projectId: z.string().min(1).describe('Project the agent lives under'),
420
449
  dest: z.string().min(1).describe('Destination directory path (absolute or relative to cwd). Show this to the user before calling.'),
421
450
  confirm: z.literal(true).describe('Must be true. Set only after the user has explicitly approved the download to the specified dest.'),
422
451
  force: z.boolean().optional().default(false)
@@ -484,8 +513,18 @@ server.tool(
484
513
  architecture: z.enum(['x86_64', 'arm64']).optional().describe('CPU architecture for the Fargate task. "arm64" runs on AWS Graviton — ~20% cheaper compute (same price to user). "x86_64" is the historical default + widest catalog compatibility. Omit to accept the catalog tile\'s preferred arch (first entry in its `architectures` array — usually arm64 for tiles that support it).'),
485
514
  model: z.string().min(1).optional().describe('Claude model identifier (e.g. claude-sonnet-4-6). Overrides the agent-ops bootstrap default. Use a stronger model (Opus) for complex installs; cheaper (Haiku) for trivial.'),
486
515
  anthropicToken: z.string().regex(/^sk-ant-(oat01|api03)-/, 'must be sk-ant-oat01-* or sk-ant-api03-*').optional().describe('Per-deploy Claude credential override. Defaults to the workspace-stored token. SENSITIVE — never log or persist.'),
516
+ maxTurns: z.number().int().min(1).max(200).optional().describe('Claude subprocess max turns. Default 25. Bump for heavy installs (n8n, OpenHands) that need background tool calls + retries.'),
517
+ timeoutMin: z.number().int().min(1).max(120).optional().describe('Bootstrap task wall-clock minutes. Default 30. Bump when npm install / docker pull / native compile dominates wall time.'),
518
+ // Optional Caddy auth sidecar — wraps the public URL with basic-auth
519
+ // or bearer-token validation. Apps like Grafana ship admin/admin and
520
+ // raw n8n/openhands ship with nothing, so gating the public URL is a
521
+ // common ask. Catalog never declares this — it's per-instance.
522
+ authType: z.enum(['basic', 'token', 'none']).optional().describe('Optional reverse-proxy auth in front of the public URL. "basic" = username + password (browser challenge), "token" = Authorization: Bearer header (machine-friendly), "none" = no gate (default, current behavior). Caddy sidecar adds ~30MB image / ~10MB RAM — no tier bump.'),
523
+ authUser: z.string().min(1).max(64).optional().describe('Username for authType=basic. Required when enabling basic. Printable ASCII, no spaces.'),
524
+ authPassword: z.string().min(8).max(256).optional().describe('Password for authType=basic. SENSITIVE — bcrypt-hashed server-side, plaintext never persisted. Required when enabling basic.'),
525
+ authToken: z.string().min(16).max(128).regex(/^[A-Za-z0-9_-]+$/, 'base64url alphabet only').optional().describe('Optional bearer token for authType=token. Omit to let the backend auto-generate a 32-byte token (returned ONCE in the deploy response — surface it to the user with a save-it warning). SENSITIVE.'),
487
526
  },
488
- async ({ appType, goal, projectId, name, provider, architecture, model, anthropicToken }) => {
527
+ async ({ appType, goal, projectId, name, provider, architecture, model, anthropicToken, maxTurns, timeoutMin, authType, authUser, authPassword, authToken }) => {
489
528
  // Enforce mutual exclusivity client-side so the agent gets a clear
490
529
  // error before we burn an HTTP round-trip. Backend enforces the
491
530
  // same invariant (apps.js::deployApp) but this gives a faster
@@ -510,12 +549,24 @@ server.tool(
510
549
  if (provider) args.push('--provider', provider);
511
550
  if (architecture) args.push('--arch', architecture);
512
551
  if (model) args.push('--model', model);
552
+ // --max-turns / --timeout-min are integer caps on the per-instance
553
+ // bootstrap. Both forwarded as argv — neither is sensitive. CLI
554
+ // re-validates the ranges (1..200, 1..120) so a malformed value 400s
555
+ // locally before hitting the backend.
556
+ if (maxTurns !== undefined) args.push('--max-turns', String(maxTurns));
557
+ if (timeoutMin !== undefined) args.push('--timeout-min', String(timeoutMin));
558
+ if (authType) args.push('--auth-type', authType);
559
+ if (authUser) args.push('--auth-user', authUser);
513
560
  // anthropicToken is SENSITIVE. Forwarded to the @zibby/cli subprocess
514
561
  // via ZIBBY_ANTHROPIC_TOKEN env (NOT argv) so the token never lands
515
562
  // in /proc/<pid>/cmdline or argv-scraping ps tools. The CLI sees the
516
563
  // env, applies the same regex check, and passes it as a body field.
564
+ // Same treatment for authPassword / authToken — both pulled by the
565
+ // CLI from env so the password / bearer never appears in argv.
517
566
  const extraEnv = { ZIBBY_API_KEY: apiKey };
518
567
  if (anthropicToken) extraEnv.ZIBBY_ANTHROPIC_TOKEN = anthropicToken;
568
+ if (authPassword) extraEnv.ZIBBY_APP_AUTH_PASSWORD = authPassword;
569
+ if (authToken) extraEnv.ZIBBY_APP_AUTH_TOKEN = authToken;
519
570
  return cliResult(await runCli(args, { extraEnv }));
520
571
  }
521
572
  );
@@ -676,6 +727,204 @@ server.tool(
676
727
  }
677
728
  );
678
729
 
730
+ // ── Tool: enable / rotate / disable the Caddy auth sidecar on a running
731
+ // instance. Per-instance opt-in reverse-proxy that gates the public URL
732
+ // with basic-auth (username + bcrypt'd password) or bearer-token
733
+ // validation. Use cases:
734
+ // - User says "lock down my Grafana with admin / hunter2"
735
+ // → authType:'basic', authUser:'admin', authPassword:'hunter2-strong'
736
+ // - User says "give me a token for my n8n public URL"
737
+ // → authType:'token' (backend auto-generates; agent prints once + warns)
738
+ // - User says "rotate the password to <new>" on an existing basic-auth
739
+ // → authPassword:'<new>' (omit authType; PATCH preserves it)
740
+ // - User says "remove auth from my app"
741
+ // → authType:'none'
742
+ server.tool(
743
+ 'zibby_set_app_auth',
744
+ 'Enable, rotate, or disable the optional Caddy reverse-proxy auth sidecar on a running app instance. The sidecar fronts the public URL with basic-auth or bearer-token validation, useful for apps that ship with weak / no built-in auth (Grafana=admin/admin, raw n8n / openhands = none). PATCH semantics: omitted fields preserve current state, so e.g. passing just authPassword on a basic-auth instance rotates the password without re-specifying the user. For token mode WITHOUT an explicit authToken, the backend generates a fresh 32-byte token and returns it ONCE in the response — surface it to the user with a save-this-now warning since it can never be retrieved again. Triggers a rolling ECS task replace (~30s); EFS data preserved.',
745
+ {
746
+ instanceId: z.string().min(1).describe('Instance ID to update auth on'),
747
+ projectId: z.string().min(1).optional().describe('Project the instance belongs to (picks the right cached API token)'),
748
+ authType: z.enum(['basic', 'token', 'none']).optional().describe('"basic" | "token" | "none". Omit to preserve current type (e.g. rotate just the password). "none" disables the sidecar — public URL exposes the app port directly again.'),
749
+ authUser: z.string().min(1).max(64).optional().describe('Username for basic auth. Required when SWITCHING TO basic; preserved on subsequent rotations.'),
750
+ authPassword: z.string().min(8).max(256).optional().describe('Password for basic auth. SENSITIVE — bcrypt-hashed server-side. Pass to rotate; omit to preserve current.'),
751
+ authToken: z.string().min(16).max(128).regex(/^[A-Za-z0-9_-]+$/, 'base64url alphabet only').optional().describe('Optional explicit bearer token for token auth. Omit on token-mode to let the backend regenerate (returned ONCE — print + warn user). SENSITIVE.'),
752
+ off: z.boolean().optional().describe('Operator-friendly alias for authType:"none". When true, disables the auth sidecar entirely.'),
753
+ },
754
+ async ({ instanceId, projectId, authType, authUser, authPassword, authToken, off }) => {
755
+ const extraEnv = {};
756
+ if (projectId) {
757
+ const apiKey = getProjectApiToken(projectId);
758
+ if (apiKey) extraEnv.ZIBBY_API_KEY = apiKey;
759
+ }
760
+ const args = ['app', 'set-auth', instanceId];
761
+ if (off) args.push('--off');
762
+ if (authType) args.push('--auth-type', authType);
763
+ if (authUser) args.push('--auth-user', authUser);
764
+ // Sensitive args via env (NOT argv) so password / token never lands in
765
+ // /proc/<pid>/cmdline or argv-scraping ps tools — same pattern as
766
+ // anthropicToken on zibby_deploy_app.
767
+ if (authPassword) extraEnv.ZIBBY_APP_AUTH_PASSWORD = authPassword;
768
+ if (authToken) extraEnv.ZIBBY_APP_AUTH_TOKEN = authToken;
769
+ return cliResult(await runCli(args, { extraEnv }));
770
+ }
771
+ );
772
+
773
+ // ─── Apps: Solo-mode (dedicated EC2 per app) ──────────────────────────────
774
+ //
775
+ // Five tools mirroring the /apps/solo/* surface. Each is a thin wrapper
776
+ // around the corresponding backend endpoint — see
777
+ // backend/src/handlers/apps-solo.js and the contract at
778
+ // backend/src/handlers/__contracts__/solo-deploy-spec.md.
779
+ //
780
+ // Why these are separate from the cloud-mode tools above:
781
+ // - Different deploy contract (repo + tier + persistence, not appType
782
+ // + goal). Conflating them in one tool overwhelms the LLM with
783
+ // params that only apply to one mode.
784
+ // - Plan/fire is a two-step flow (LLM sees the validation errors
785
+ // from plan, fixes the spec, then fires). The cloud `deploy_app`
786
+ // tool is single-step. Different ergonomics.
787
+ //
788
+ // Tier SSOT lives in backend/src/config/plans.js (SOLO_TIERS). The
789
+ // plan response echoes `pricing.soloTiers` so the LLM can quote the
790
+ // live table to the user; the zod enum below is a compile-time
791
+ // guardrail mirroring the same ids.
792
+
793
+ // Common nested zod schemas — defined once so the plan/fire tools share
794
+ // validation. Treated as the canonical TS shape inline; the contract
795
+ // doc has the authoritative comments.
796
+ const soloSourceSchema = z.union([
797
+ z.object({
798
+ type: z.literal('github'),
799
+ repo: z.string().min(1).describe('GitHub repo in "owner/name" form'),
800
+ ref: z.string().min(1).optional().describe('Git ref (branch, tag, sha). Defaults to repo default branch.'),
801
+ }),
802
+ z.object({
803
+ type: z.literal('tarball'),
804
+ s3Url: z.string().min(1).describe('s3:// URL of the source tarball'),
805
+ }),
806
+ ]);
807
+
808
+ const soloSecretSchema = z.object({
809
+ key: z.string().min(1).describe('Env var name'),
810
+ valueRef: z.string().min(1).optional().describe('Pointer at workspace-credentials (e.g. "workspace:stripe-prod"). Plaintext is NOT accepted — the backend rejects any `value` field.'),
811
+ });
812
+
813
+ const soloPersistenceSchema = z.object({
814
+ db: z.enum(['sqlite-litestream', 'postgres-walg', 'none']).optional(),
815
+ files: z.enum(['activestorage-s3', 'rclone-bisync', 'none']).optional(),
816
+ });
817
+
818
+ const soloTierEnum = z.enum(['micro', 'small', 'medium', 'large']);
819
+
820
+ // Region SSOT mirror — backend/src/config/plans.js SOLO_REGIONS. The
821
+ // control plane stays in Sydney; this only moves the per-app EC2 + S3 +
822
+ // log group. Pricing is flat across regions (v1). Validated with
823
+ // .refine against the explicit list so an unknown region fails fast
824
+ // LLM-side rather than round-tripping to a backend 400.
825
+ const SOLO_REGION_IDS = ['ap-southeast-2', 'us-east-1', 'us-west-2', 'eu-west-1', 'ap-northeast-1'];
826
+ const soloRegionSchema = z.string()
827
+ .refine((r) => SOLO_REGION_IDS.includes(r), {
828
+ message: `region must be one of ${SOLO_REGION_IDS.join(', ')}`,
829
+ });
830
+
831
+ // Full + partial spec shapes. plan_solo_deploy takes the partial
832
+ // (everything optional) and surfaces missingInputs / ambiguities back
833
+ // to the LLM. fire_solo_deploy demands the full shape.
834
+ const soloPartialSpec = z.object({
835
+ appSlug: z.string().min(1).max(40).optional()
836
+ .describe('lowercase [a-z0-9-]; forms the public hostname <slug>.apps.zibby.dev'),
837
+ source: soloSourceSchema.optional(),
838
+ framework: z.enum(['auto', 'rails', 'node', 'python', 'static', 'other']).optional(),
839
+ tier: soloTierEnum.optional()
840
+ .describe('micro/small/medium/large — see plan response pricing.soloTiers for the live table.'),
841
+ region: soloRegionSchema.optional()
842
+ .describe([
843
+ 'AWS region the VM runs in. One of:',
844
+ ' ap-southeast-2 (Sydney) — Oceania, default/control-plane region',
845
+ ' us-east-1 (N. Virginia) — North America (east), also nearest for South America',
846
+ ' us-west-2 (Oregon) — North America (west)',
847
+ ' eu-west-1 (Ireland) — Europe, also nearest for Africa/Middle East',
848
+ ' ap-northeast-1 (Tokyo) — Asia, nearest for East Asia incl. China/Korea, and the closest option for India/SE Asia',
849
+ 'PICK THE NEAREST region to the user for lowest latency — you already know their',
850
+ 'locale/timezone from this conversation, so infer it (e.g. UTC+10 → Sydney, UTC+8 → Tokyo,',
851
+ 'US Pacific → Oregon, US Eastern/Central → N. Virginia, Europe → Ireland) and pass it',
852
+ 'explicitly rather than defaulting. Only the EC2 + S3 + logs move to this region; the',
853
+ 'Zibby control plane stays in Sydney. Pricing is identical in every region, so choose',
854
+ 'purely on proximity. If you genuinely cannot tell where the user is, omit it (defaults',
855
+ 'to Sydney) or ask them.',
856
+ ].join('\n')),
857
+ secrets: z.array(soloSecretSchema).optional(),
858
+ domain: z.string().optional().describe('Server canonicalizes to <slug>.apps.zibby.dev — value passed here is overridden.'),
859
+ persistence: soloPersistenceSchema.optional(),
860
+ });
861
+
862
+ const soloCompleteSpec = soloPartialSpec.required({
863
+ appSlug: true,
864
+ source: true,
865
+ tier: true,
866
+ });
867
+
868
+ server.tool(
869
+ 'plan_solo_deploy',
870
+ 'Validate a (possibly partial) solo-mode deploy spec server-side. Returns one of: (a) `{ok:true, spec, costEstimate, summary, pricing}` when the spec is complete + the user has enough credits — caller can move straight to fire_solo_deploy; (b) `{ok:false, spec, missingInputs:[{field,prompt,suggested?,options?}], ambiguities:[{field,prompt,options}], pricing}` when there are gaps — caller picks one field at a time, asks the user, fills the spec, and re-calls; (c) `{ok:false, ..., insufficientCredits:{balanceMilliCents,neededMilliCents,hint}}` when the spec is complete but the wallet is empty — caller surfaces the top-up hint. NEVER provisions anything — pure validation. `pricing.soloTiers` always echoes the live tier table; render it for the user when they need to pick. Mirrors POST /apps/solo/plan.',
871
+ {
872
+ spec: soloPartialSpec.describe('Partial spec — every field is optional. Backend reports back what is missing.'),
873
+ },
874
+ async ({ spec }) => {
875
+ // The MCP doesn't have a project context; we shell out to a generic
876
+ // fetch via the @zibby/cli session token. Future: convert to a
877
+ // dedicated CLI subcommand if this becomes a hot path. For now we
878
+ // dump the spec to a flag the CLI hasn't surfaced yet, falling back
879
+ // to an explicit error so the LLM knows the wiring is partial.
880
+ // The actual call shape parallels fetchJson in commands/app.js.
881
+ return cliResult(await runCli(['app', '_solo-plan', JSON.stringify(spec)]));
882
+ },
883
+ );
884
+
885
+ server.tool(
886
+ 'fire_solo_deploy',
887
+ 'Launch a solo-mode EC2 instance from a COMPLETE spec. Spec must have appSlug + source + tier filled in — otherwise the server returns 400 with the same missingInputs shape (re-run plan_solo_deploy to fill the gaps). Returns `{appSlug, instanceId, deploymentId, statusUrl, mode:"solo", tier, domain}`. Bills against the user\'s credit balance per backend/src/config/plans.js SOLO_TIERS (monthly flat fee, prorated per minute). On insufficient credits returns 402 — surface the user-friendly top-up hint to the user, do not retry. The instance bootstraps via cloud-init + agent-ops; poll get_solo_status until phase=running (or =failed). Mirrors POST /apps/solo/fire.',
888
+ {
889
+ spec: soloCompleteSpec.describe('Complete SoloDeploySpec — must include appSlug, source, tier. The server re-validates and 400s if anything is missing; it will NOT silently re-run plan.'),
890
+ },
891
+ async ({ spec }) => {
892
+ return cliResult(await runCli(['app', '_solo-fire', JSON.stringify(spec)]));
893
+ },
894
+ );
895
+
896
+ server.tool(
897
+ 'get_solo_status',
898
+ 'Get the current bootstrap/run phase of a solo-mode app instance. Returns `{appSlug, phase, detail}` where phase is one of `provisioning | bootstrapping | deploying | running | failed | unknown`. Use this to poll after fire_solo_deploy — typical happy path: provisioning (~30s) → bootstrapping (~2min) → deploying (~2min) → running. Mirrors GET /apps/solo/{slug}/status.',
899
+ {
900
+ appSlug: z.string().min(1).describe('App slug returned from fire_solo_deploy'),
901
+ },
902
+ async ({ appSlug }) => {
903
+ return cliResult(await runCli(['app', '_solo-status', appSlug]));
904
+ },
905
+ );
906
+
907
+ server.tool(
908
+ 'list_solo_apps',
909
+ 'List the user\'s solo-mode app instances (sibling to zibby_list_apps which lists cloud-mode instances). Returns an array of `{appSlug, instanceId, tier, phase, domain, createdAt}`. Useful when the user asks "show my solo apps" or "what solo instances am I paying for?". (Wired to the same backend listApps endpoint with a mode=solo filter once the row schema lands.)',
910
+ {},
911
+ async () => {
912
+ return cliResult(await runCli(['app', 'list', '--mode', 'solo']));
913
+ },
914
+ );
915
+
916
+ server.tool(
917
+ 'stop_solo_app',
918
+ 'Stop and destroy a solo-mode app instance. DESTRUCTIVE: terminates the EC2 instance, releases the EBS root volume, removes the row. Litestream replica in S3 is PRESERVED — the user can spin up a new instance pointed at the same bucket to restore. The agent MUST first ask the user for explicit confirmation, then call this with confirm=true.',
919
+ {
920
+ appSlug: z.string().min(1),
921
+ confirm: z.literal(true).describe('Must be true. Set only after the user has explicitly approved destroying this instance.'),
922
+ },
923
+ async ({ appSlug }) => {
924
+ return cliResult(await runCli(['app', 'destroy', appSlug, '--yes', '--mode', 'solo']));
925
+ },
926
+ );
927
+
679
928
  // ── Connect ─────────────────────────────────────────────────────────────
680
929
 
681
930
  await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zibby/mcp-cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.8",
4
4
  "description": "Zibby local-essential MCP Server — local workflow scaffold/validate/run + deploy/download (bundles local files). Pure-API tools live in the Zibby Remote MCP (api-prod.zibby.app/mcp).",
5
5
  "type": "module",
6
6
  "main": "index.js",