Skip to content

Add MCP tools for pipeline creation, stages and deploys - #8500

Merged
cstns merged 4 commits into
mainfrom
7697-mcp-pipeline-write-tools
Sep 22, 2026
Merged

cstns merged 4 commits into
mainfrom
7697-mcp-pipeline-write-tools

Conversation

@cstns

@cstns cstns commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Closes #7697

Adds the five pipeline write tools: platform_create_pipeline, platform_update_pipeline, platform_add_pipeline_stage, platform_update_pipeline_stage and platform_deploy_pipeline_stage. With this, agents can finally run the deploy step of a pipeline.

A few route realities shaped the handlers and descriptions:

  • PUT /pipelines/:id declares a flat { name } body but its handler reads request.body.pipeline.name, so the tool keeps a flat agent-facing input and wraps it into the nested shape (the route's own tests use the nested shape too). Might be worth a follow-up fix on the route itself.
  • Stage updates only apply git settings inside the rebinding branch, which is triggered by instanceId/deviceId/deviceGroupId being present in the body. The frontend gets this by always sending a null deviceGroupId (AJV coerces it to ''); the tool handler mirrors that by adding a blank deviceGroupId when only git fields are sent, so a git-only update actually lands.
  • addGitRepo resets omitted git fields to empty strings on update, except credentialSecret which is kept. The field descriptions tell agents to resend the full git settings on every git update.
  • Deploy replies { status: "importing" } as soon as the deploy has started and finishes in the background, so the description tells agents to poll the target status instead of treating the 200 as completion. The protected-instance Owner gate (403 protected_instance), the prompt-action sourceSnapshotId, and the action-none no-op are also spelled out.
  • Stage ordering is a linked list: source is how a new stage is attached after an existing one, and the API's ordering rules (no device group first, no instance/device after a device group) are in the description since the errors are generic invalid_input.

@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.22%. Comparing base (1ab067a) to head (dc33a46).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8500      +/-   ##
==========================================
+ Coverage   77.13%   77.22%   +0.08%     
==========================================
  Files         466      466              
  Lines       25004    25097      +93     
  Branches     6664     6684      +20     
==========================================
+ Hits        19287    19380      +93     
  Misses       5717     5717              
Flag Coverage Δ
backend 77.22% <100.00%> (+0.08%) ⬆️

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 self-requested a review September 14, 2026 17:02

@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.

Route behavior all checks out, and the tricky bits are handled well: the nested pipeline.name wrap, the blank deviceGroupId to reach the git rebind branch, and the deploy status semantics. Tests cover them nicely.

One optional cleanup, mostly on the two stage tools since they overlap a lot:

  • The descriptions restate logic that already lives in the args (action, the "resend git settings as a set / reset to empty" note, source/linked-list). Could those move fully into the args that own them, keeping the description tool-level? Same idea we landed on for the update-application tool. I'm talking about this comment #8496 (comment)
  • platform_add_pipeline_stage and platform_update_pipeline_stage duplicate the whole git field block, where the only real difference is the "reset to empty" note on update. Could it be a shared fragment in schemas.js (next to teamId/applicationId), spread into both? Something like:
// schemas.js
function gitStageFields ({ resetNote = false } = {}) {
    const reset = resetNote ? '. Resent on every git update; reset to empty when omitted' : ''
    return {
        url: z.string().optional().describe('Git repository URL' + reset),
        branch: z.string().optional().describe('Git branch to push to' + reset),
        pullBranch: z.string().optional().describe('Git branch to pull from' + reset),
        pushPath: z.string().optional().describe('Repository path to push to' + reset),
        pullPath: z.string().optional().describe('Repository path to pull from' + reset),
        credentialSecret: z.string().optional().describe('Secret used to encrypt flow credentials pushed to the repository' + (resetNote ? '. Keeps its stored value when omitted' : ''))
    }
}
  • The handler payload key list is duplicated between the two as well, so it could be a shared const.

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

Deploying replaces the flows running on the next stage's target rather than
adding to them, which is the same reasoning that puts
platform_set_instance_device_target behind destructive access, so this
belongs there too.

"A stage added without source is not linked into the chain" undersold it.
Adding a source-less stage to a pipeline that already has stages leaves two
unlinked heads, and the pipeline then lists only the new one while the
existing stages stop appearing, so a single missing argument reads as if it
wiped the pipeline (#8582). Say that, on the description and on the field.
@cstns

cstns commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Tested against a running platform with a throwaway application, two hosted instances, a device group and a git token.

The route reading behind this PR holds up well, and both handler workarounds turned out to be load-bearing rather than defensive:

  • the nested-body wrap is required. The flat {name} body the route's own schema declares gives 500 TypeError: Cannot read properties of undefined (reading 'name'); only {pipeline:{name}} works (PUT /api/v1/pipelines/:id declares a flat body but reads a nested one #8583)
  • the blank deviceGroupId on git-only updates is required. I ran identical payloads differing only by that key: without it the update returns 200 and changes nothing, with it the settings land (Git-only pipeline stage update returns 200 and changes nothing #8584)
  • "omitted git fields are reset to empty, credentialSecret is the exception" is exactly right. A partial update left url changed and branch, pullBranch, pushPath, pullPath all '', credential secret intact

Deploy behaves as described throughout: {"status":"importing"} with genuine background completion (a Deploy Snapshot appeared on the target), 404 not_found from the last stage, action: none giving {"status":"okay"} and doing nothing, and action: prompt giving 400 invalid_source_snapshot without a sourceSnapshotId and deploying with one. Ordering and target rules all enforced as documented.

Two changes in 9415a58:

Deploy is now delete-class. It replaces the flows running on the next stage's target rather than adding to them, which is the same reasoning that made platform_set_instance_device_target destructive in #8497. Leaving it as a plain write next to renaming a pipeline felt inconsistent.

The source warning was too soft. "A stage added without source is not linked into the chain" undersells what actually happens. On a pipeline already containing a stage, adding one without source leaves two unlinked heads and the listing then returns only the new stage:

before: stages: ['GitStage']
after:  stages: ['NoSource']

GitStage still exists and fetches fine by id, it just stops appearing. So one missing argument reads as if it wiped the pipeline. Raised the listing behaviour as #8582.

One small correction to the PR description: it says the ordering rules are spelled out "since the errors are generic invalid_input". The code is generic but the error text is specific ("A Device Group cannot be the first stage", "An instance or device cannot be added after a device group"), and since the gateway drops code and surfaces error (#8572), an agent sees the clear message. Documenting the rules is still worth it, the reasoning just isn't quite right.

Everything above was exercised at the route level with payloads matching exactly what each handler sends; the tool-level pass is pending a gateway catalog refresh.

@cstns
cstns marked this pull request as ready for review September 21, 2026 13:56
@cstns
cstns requested a review from andypalmi September 21, 2026 13:56
@andypalmi

Copy link
Copy Markdown
Contributor

The destructive reclassification and the sharper source warning both read well, and the ordering-error correction is fair.

Coming back to the two cleanups from the last pass, since this is the same shape as what we landed on #8497:

  1. The stage descriptions still restate logic the args already carry (the action modes, the git reset-to-empty note, the source linked-list rule). Could those move fully into the arg .describe() that owns them, keeping the description tool-level, the way we did for the update-application tool?

  2. platform_add_pipeline_stage and platform_update_pipeline_stage still duplicate the whole git field block, the only real difference being the reset-to-empty note on update. Could it become a shared fragment in schemas.js (next to teamId/applicationId), spread into both, with the handler payload key list shared too?

Can you fold these in before this one merges, the same way the shared components schema went into #8497?

…ptions

The add and update stage tools carried the same seven git fields twice, so
they move to a gitStageFields fragment in schemas.js alongside the other
shared pieces. The one real difference between the two, that an update
applies the git settings as a set and empties anything omitted while
credentialSecret keeps its value, is what the forUpdate flag carries, so the
caveat still lives on the arguments that own it rather than in prose.

action and deployToDevices were duplicated too and are now declared once,
and both handlers build their payload from one shared key list.

The descriptions kept restating logic the arguments already carry: the
action modes, the git resend rule, the source linked-list rule, and the
partial-update note. Those move onto their arguments, leaving the
descriptions to say what each tool does plus the ordering rules, which are
cross-field and belong to neither.

No behaviour change. The published JSON Schema for both tools is identical
before and after, same keys in the same order, same types, same required
sets; only the descriptions differ.
@cstns

cstns commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Both folded in, 44304c5.

Shared git block. gitStageFields now lives in schemas.js next to the other shared pieces, spread into both stage tools. It takes a forUpdate flag because that is the only genuine difference between the two: on update the git settings are applied as a set so omitted fields empty out, with credentialSecret as the exception. Putting the flag in the fragment keeps that caveat on the arguments that own it rather than pushing it back into prose, which seemed truer to the first point than leaving a neutral fragment and re-stating the difference in the description.

action and deployToDevices were duplicated too, so they are declared once as well, and both handlers now build their payload from a shared stagePayloadKeys list ([...stagePayloadKeys, 'source'] for add).

Descriptions. Moved onto the arguments that own them: the action modes, the git resend rule, the source linked-list rule, and the partial-update note. What is left is what each tool does plus the ordering rules, which I kept tool-level since they are cross-field invariants that no single argument owns, and you did not list them among the things to move. Shout if you would rather they hung off deviceGroupId.

One thing worth confirming: the source warning was deliberately in both places, because the failure mode is nasty (a missing source leaves the pipeline listing showing only the new stage, #8582). It now appears once, on the source argument, which is consistent with the principle but does drop the redundancy that was there on purpose. Happy either way, just flagging it rather than quietly halving it.

No behaviour change. I diffed the generated JSON Schema for both tools before and after: identical keys in the same order, same types, same required sets, same additionalProperties, with only the descriptions differing. 241 MCP tests still pass and lint is clean.

@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.

Cleanups look good. gitStageFields in schemas.js with the forUpdate flag is the right shape, and the shared action / deployToDevices / stagePayloadKeys deduplication matches what we did on #8497. Confirmed the generated schema is unchanged bar descriptions.

On the source warning: keep it in both places. The redundancy is deliberate given how the failure reads (a missing source looks like it wiped the pipeline, #8582), so I'd rather it stay slightly over-stated than have someone miss it.

Only thing left is the merge conflict with main in schemas.js (both this and #8497's snapshotComponents fragment landed in the same spot, purely additive). Resolve that against main and this is good to merge.

@cstns
cstns enabled auto-merge (squash) September 22, 2026 11:45
@cstns
cstns merged commit d885e10 into main Sep 22, 2026
29 checks passed
@cstns
cstns deleted the 7697-mcp-pipeline-write-tools branch September 22, 2026 12:07

This branch was successfully deployed

1 active deployment
staging — dc33a461 Deployed Sep 22, 2026 by cstns via Remove application #11838
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.5-b Write tools, non-destructive (phase 2)

2 participants