Skip to content

Add MCP tools for snapshot export, import, update and device target - #8497

Draft
cstns wants to merge 3 commits into
mainfrom
7688-mcp-snapshot-write-tools
Draft

cstns wants to merge 3 commits into
mainfrom
7688-mcp-snapshot-write-tools

Conversation

@cstns

@cstns cstns commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Closes #7688

Adds four snapshot write tools:

  • platform_update_snapshot wraps PUT /api/v1/snapshots/:id. The controller does proper partial updates, so the tool only sends the fields it was given. A blank name is not cleanly rejected: the controller throws a sequelize ValidationError and nothing maps that to a status code, so the route answers 500. The tool catches it first (whitespace only counts as blank, since the controller trims) and returns a 400. An update with no fields at all gets a 200 back with the snapshot unchanged, which reads as a successful edit that never happened, so the tool rejects that too.
  • platform_export_snapshot wraps POST /api/v1/snapshots/:id/export. credentialSecret is unconditionally required by the route (400 without it), and the description warns that the default export includes hidden env var values, plus a reminder that the same secret is needed at import time.
  • platform_import_snapshot wraps POST /api/v1/snapshots/import. One real quirk surfaced while reading the controller: uploadSnapshot calls Object.keys(snapshot.settings.env) unguarded, so a snapshot without settings.env 500s. The handler normalises an omitted env to {} so agents don't hit that. Same story for hidden env values, which get decrypted before the component filtering runs, so a keys-only or env-excluded import would 500 without a secret it does not actually need; the tool reduces env up front in both cases and the result is identical to what the route produces on its happy path. One rough edge left: the export response carries six extra fields (id, createdAt, updatedAt, ownerType, user, exportedBy) that this tool's snapshot argument does not accept, so pasting an export straight back in fails validation with unrecognized key. The description spells out which four fields to copy, but it might be worth letting the schema ignore the extras so the obvious export then import flow just works.
  • platform_set_instance_device_target wraps POST /api/v1/projects/:id/devices/settings. The description carries a caution that setting the target deploys immediately to every assigned device, and notes the route can only set a target, not clear one. The tool makes snapshotId required because the route's only reply.send sits inside the if (request.body.targetSnapshot) block: omit it and you get a 200 with an empty body and nothing changed, a silent no-op rather than an error. It is also annotated destructiveHint: true, since it overwrites what every assigned device is running rather than adding to it, which puts it behind destructive tool access instead of plain write.

Descriptions were written from the actual route/controller behavior rather than assumptions, including the owner resolution (instance or device) happening from the snapshot itself. The behaviors above were checked against a running platform, calling each route directly and invoking the matching tool with the same arguments.

@cstns cstns self-assigned this Sep 14, 2026
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.07%. Comparing base (fe5b27d) to head (593093a).
⚠️ Report is 36 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8497      +/-   ##
==========================================
+ Coverage   76.88%   77.07%   +0.18%     
==========================================
  Files         460      466       +6     
  Lines       24778    24975     +197     
  Branches     6609     6650      +41     
==========================================
+ Hits        19051    19249     +198     
+ Misses       5727     5726       -1     
Flag Coverage Δ
backend 77.07% <100.00%> (+0.18%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@andypalmi andypalmi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four tools line up with the routes and controllers, and the tricky bits are handled well: the partial update with the empty-name guard, the settings.env normalization that dodges the unguarded Object.keys in uploadSnapshot, and making the device target snapshot required so the route (which never replies when it is missing) can't hang. Tests cover them nicely.

One optional cleanup, same theme as the pipeline stage tools: platform_export_snapshot and platform_import_snapshot carry near-identical components schemas. The differences are just the direction wording and the "exposes hidden values" caution, and that caution already lives in the export tool's description, so repeating it in the arg is the kind of duplication worth dropping. Could this be a single shared schema in schemas.js (next to snapshotId/hostedInstanceId), spread into both?

// schemas.js
const snapshotComponents = z.object({
    flows: z.boolean().optional().describe('Include flows (default true). Excluding flows also excludes credentials'),
    credentials: z.boolean().optional().describe('Include the flow credentials (default true)'),
    envVars: z.union([z.literal('all'), z.literal('keys'), z.literal(false)]).optional().describe('Environment variables: "all" keeps keys and values (default), "keys" keeps only the names, false removes them')
}).optional()

And a smaller one: a couple of descriptions restate mechanics that already live in the args, for example the non-empty name and empty-string-to-clear notes on platform_update_snapshot. Could those stay only in the arg .describe() that owns them, keeping the description tool-level? The owner-resolution, partial-update, and immediate-deploy caution are genuinely tool-level and read well where they are.

None of this is blocking, happy for it to be a follow-up if you would rather keep this PR focused.

…s, enforce upfront validation for encrypted data, and handle excluded components properly. Add tests for various encrypted scenarios.
@cstns

cstns commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up to the import tool after a closer read of the controller.

Hidden env vars are also exported encrypted (the env entry gets a $), and uploadSnapshot decrypts them before the flow-credentials guard, iterating the original snapshot rather than the component-filtered copy. Two consequences the tool didn't account for:

  • Importing a snapshot with hidden env vars and no credentialSecret blows up with a raw 500, even with components.envVars: false, because the decrypt runs before the filtering and the catch regex doesn't match the crypto error.
  • A wrong secret only produces the documented 400 when flow credentials are present. Without them there's nothing to validate against (env decryption is unauthenticated aes-256-ctr), so hidden values import as silent garbage with a 200.

Since both live in the route/controller, the tool handles them client-side for now:

  • Env vars are stripped up front when envVars: false. The route ends up emptying them anyway, it just does the decrypt first, so this is the same request without the 500.
  • A snapshot carrying encrypted material (hidden env $, or flows.credentials.$ when credentials aren't excluded) with no credentialSecret is rejected with a clear 400 before the route is called.
  • The description now says when the secret is genuinely needed, and warns that a wrong one can't be detected without flow credentials.

Tests cover the strip, both rejection paths, and the two cases that should still go through.

Guarding the decrypt loop in uploadSnapshot itself (and running it on the filtered copy) is the real fix, might be worth a separate issue.

Mark platform_set_instance_device_target destructive: it overwrites the
target on every device assigned to the instance, so it belongs behind
destructive tool access rather than plain write.

Guard platform_update_snapshot: tool input is not validated platform-side,
so a blank name reached the controller and surfaced as a 500, and an update
with no fields got a 200 with the snapshot unchanged.

Reduce env vars to their names up front on a keys-only import. The route
discards the values anyway, but decrypts the hidden ones first, which forced
a credentialSecret the caller does not need.

Share one components schema between export and import, use the toolError
helper for the tool's own 400, and keep arg-level mechanics in the arg
descriptions rather than repeating them in the tool description.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5.2-b Write tools, non-destructive (phase 2)

2 participants