Skip to content

feat: tags on MCPServer and Workflow - #1740

Open
Rotfuks wants to merge 5 commits into
mainfrom
feat/platform-tags
Open

Rotfuks wants to merge 5 commits into
mainfrom
feat/platform-tags

Conversation

@Rotfuks

@Rotfuks Rotfuks commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Free-form tags on MCPServers and Workflows, so the agent-platform portal can filter, search and link connectors and workflows by tag, like agents. Towards giantswarm/giantswarm#38901.

What changes

  • tags arg (string array) on core_mcpserver_create/update/validate and core_workflow_create/update/validate.
  • Validated: ^[a-z0-9]+(-[a-z0-9]+)*$, at most 63 characters, unique, at most 50 tags. Refusals come back as a tool error: invalid tags: ….
  • Stored as the comma-joined annotation ui.giantswarm.io/tags.
  • Update semantics: omitted keeps, [] clears, a list replaces. Other annotations (registered-by*, display-name) survive.
  • core_mcpserver_get/list and core_workflow_get/list return tags (omitted when empty). Readers drop malformed entries instead of failing.
  • Docs (docs/reference/mcp-tools.md, docs/reference/crds.md), schema.json, CHANGELOG.

Compatibility

Annotation-only: no CRD, RBAC or chart change. The portal checks each tool's input schema for tags and never sends it to a muster without this change.

GitOps-owned objects behave like display-name: if the manifest declares the annotation, Flux reverts a live edit.

Testing

  • Unit tests for the helpers and for create/update/get/list, including the replace semantics and the 50-tag cap.
  • New scenarios mcpserver-tags and workflow-tags; the mcpserver and workflow scenario concepts pass.
  • go vet, golangci-lint clean.
  • Ran as a dev image in agentlab with the portal change; tags round-trip through the tools and show in the portal.

The portal side is a separate giantswarm/backstage PR.

Known limits (unchanged by this PR)

  • core_workflow_update still replaces the whole spec and requires steps, so a tags-only update must resend the spec. Only tags has keep-when-omitted semantics.
  • No --tags flag on muster create; the CLI is out of scope.

core_mcpserver_create/update/validate and core_workflow_create/update/validate
take a tags argument, validated (^[a-z0-9]+(-[a-z0-9]+)*$, at most 63
characters, unique) and stored comma-joined in the ui.giantswarm.io/tags
annotation. On update an omitted tags keeps them, [] removes them, a list
replaces them. get/list return tags as an array, dropping malformed entries.
core_workflow_create/_update/_validate refuse invalid tags as a tool error worded as core_mcpserver_*'s ("invalid tags: ...") instead of a protocol error or a doubled "validation failed" prefix. updateWorkflow takes the parsed tri-state tags from WorkflowUpdateRequest. tagsArg returns a fresh ArgMetadata per tool. Adds the workflow-tags scenario.
ValidateTags refuses more than api.MaxTags (50) tags with "at most 50 tags", so core_mcpserver_* and core_workflow_* answer "invalid tags: at most 50 tags". ParseTags does not cap on read. Matches the developer portal's cap on agents, connectors and skills.
@circleci-architect

circleci-architect Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Published Helm chart muster-crds

5.54.6-reecc69cat20261102124207h2c3ed80
Chart muster-crds
Version 5.54.6-reecc69cat20261102124207h2c3ed80
OCI reference oci://gsoci.azurecr.io/charts/giantswarm/muster-crds:5.54.6-reecc69cat20261102124207h2c3ed80
Digest sha256:71e48789117315c477a75c84af639c7a274390598fb2058932cf2ffbe75e5fb1
Registry public — gsoci.azurecr.io
Git catalog giantswarm-test-catalog (index)
Pull this chart
helm pull oci://gsoci.azurecr.io/charts/giantswarm/muster-crds --version 5.54.6-reecc69cat20261102124207h2c3ed80

Posted by architect-orb · build 43437 · commit 2c3ed80 · updated in place on every push

@circleci-architect

circleci-architect Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Published Helm chart muster

5.54.6-reecc69cat20261102124207h2c3ed80
Chart muster
Version 5.54.6-reecc69cat20261102124207h2c3ed80
OCI reference oci://gsoci.azurecr.io/charts/giantswarm/muster:5.54.6-reecc69cat20261102124207h2c3ed80
Digest sha256:cbdbcb3b6661d6bebd23f5da25e624e4a92d16dc6f960c6dd546393672f19962
Registry public — gsoci.azurecr.io
Git catalog giantswarm-test-catalog (index)
Pull this chart
helm pull oci://gsoci.azurecr.io/charts/giantswarm/muster --version 5.54.6-reecc69cat20261102124207h2c3ed80

Posted by architect-orb · build 43433 · commit 2c3ed80 · updated in place on every push

Comment thread internal/api/tags.go
// lowercases each entry, and drops entries that are not valid tags and
// repeats. It does not enforce MaxTags. It never fails, so a malformed annotation set by hand does not
// break a read. It returns nil when no valid tag remains.
func ParseTags(raw string) []string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ParseTags returns more than MaxTags on read while ValidateTags refuses more than MaxTags on write, so a resource with an over-long hand-set or GitOps tag list can't have its tags edited by echoing them back.

A manifest sets ui.giantswarm.io/tags to 60 valid tags. core_mcpserver_get returns all 60. The portal user removes one tag and sends the remaining 59 to core_mcpserver_update, which is refused with invalid tags: at most 50 tags. Any edit fails until the user removes 10 tags they never added. Either cap the read at MaxTags or let an update that doesn't grow the list through.

Comment thread internal/api/tags.go
seen := map[string]struct{}{}
for _, entry := range strings.Split(raw, ",") {
tag := strings.ToLower(strings.TrimSpace(entry))
if checkTag(tag) != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Readers silently drop malformed entries, and a round-trip update (get, edit, send the list back) then permanently deletes those entries from the annotation without saying so.

Someone sets ui.giantswarm.io/tags: team_a,triage with kubectl. core_workflow_get returns ["triage"]. The portal adds k8s and sends ["triage","k8s"] with replace semantics, so the stored annotation becomes triage,k8s and team_a is gone without a warning. Normalising (Triage to triage) also rewrites the stored value.

existing.Spec.Description = req.Description
}
updateDisplayName(existing, req.DisplayName)
existing.Annotations = api.ApplyTagsAnnotation(existing.Annotations, req.Tags)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

handleMCPServerUpdate does a get-modify-update with no conflict retry, so a tags edit from the portal fails if the status reconciler bumps resourceVersion between the Get and the Update. The workflow update path retries with retry.RetryOnConflict.

In Kubernetes mode a user saves new tags just as the MCPServer reconciler writes status. The Update carries the stale resourceVersion, the apiserver answers 409 Conflict, and the tool returns Failed to update MCP server, although nothing about the tags conflicted. updateWorkflow handles the same case with RetryOnConflict.

Comment thread internal/workflow/tags.go Outdated
Type: api.ArgTypeArray,
Required: false,
Description: api.TagsArgDescription,
Schema: map[string]interface{}{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The tags argument's JSON Schema only declares an array of strings. The real constraints (maxItems 50, uniqueItems, item pattern ^[a-z0-9]+(-[a-z0-9]+)*$, maxLength 63) appear only in the prose description, so schema-driven clients can't validate before calling.

The portal already reads each tool's input schema to find tags. It can't derive the tag rules from that schema, so it has to hard-code the regex and limits again, and the copies can drift. An LLM client following the schema sends ["Triage"] and gets a refusal it could have avoided. Adding maxItems, uniqueItems and items.pattern/items.maxLength to the schema (once, in a shared helper) fixes both. The same applies to the inline schema in internal/mcpserver/api_adapter.go:655.

Comment thread internal/workflow/api_adapter.go Outdated
workflowInfo["description"] = wf.Description
}
if len(wf.Tags) > 0 {
workflowInfo[fieldTags] = wf.Tags

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Adding tags to each core_workflow_list entry changes muster list workflow table output: the table formatter fills free columns alphabetically, so a TAGS column now appears. The CHANGELOG doesn't mention it.

optimizeColumns for workflows uses name, description, then fills up to 6 columns alphabetically from all keys (available, tags). With any tagged workflow, muster list workflow gains a tags column showing a raw list. Scripts parsing the table see a new column. Either add tags to unwantedColumnsByResourceType for workflows or document the CLI change.

}
existing.Spec = desired.Spec
// Tri-state: omitted tags (nil) keep the current tags.
existing.Annotations = api.ApplyTagsAnnotation(existing.Annotations, tags)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

updateWorkflow now has two sources for tags: the tags parameter (from ParseRequest), which is applied, and wf.Tags/desired.Annotations (from convertTags on the same args), which are computed and thrown away. The comment above (only its spec is applied onto the live object) is now stale.

A future change that copies desired.ObjectMeta or desired.Annotations onto existing would turn an omitted tags into a clear, because desired carries nil annotations, which breaks the keep-when-omitted contract. Pass a single source (for example set wf.Tags only on create and drop the separate parameter, or skip building annotations in the update's desired object) and update the comment.

Comment thread internal/workflow/tags.go Outdated

// convertTags reads and validates a tags argument. nil (omitted) yields no
// tags.
func convertTags(raw interface{}) ([]string, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

convertTags re-decodes and re-validates the tags argument that api.ParseRequest already decoded into req.Tags and invalidTagsResult already validated, so a single core_workflow_create checks the tags four times.

handleCreate runs ParseRequest and invalidTagsResult, then convertToWorkflow (convertTags), then createWorkflow, which runs ValidateWorkflowFromStructured (convertToWorkflow again) and convertToWorkflow a third time. That is four tag validations and three hand-written type assertions duplicating the JSON decode, with two different error paths for the same input. Validate once (either in the handler from req.Tags, or in convertToWorkflow) and pass the parsed tags through.

Comment thread internal/mcpserver/api_adapter.go Outdated
api.SchemaKeyRequired: []string{"name", "instanceArg"},
}},
{Name: "displayName", Type: api.ArgTypeString, Required: false, Description: "Name UIs show for the server, stored trimmed in the ui.giantswarm.io/display-name annotation; blank sets none, on update removes it"},
{Name: "tags", Type: api.ArgTypeArray, Required: false, Description: api.TagsArgDescription, Schema: map[string]interface{}{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The tags ArgMetadata (name, type, description, items schema) is defined twice: inline here and as tagsArg() in internal/workflow/tags.go. It belongs in internal/api/tags.go next to TagsArgDescription.

Any schema change (for example adding the maxItems/pattern constraints) has to be made in two packages, and the MCPServer and Workflow tools' tags schemas can drift. An api.TagsArg() helper, used by both adapters, removes the copy.

Comment thread internal/workflow/tags.go Outdated

// invalidTagsResult refuses tags that fail api.ValidateTags with a tool
// error worded as core_mcpserver_*'s. It returns nil for valid tags.
func invalidTagsResult(tags []string) *api.CallToolResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The invalid tags: %v refusal and the if req.Tags != nil { validate(*req.Tags) } update guard are written out once here (invalidTagsResult) and three more times inline in internal/mcpserver/api_adapter.go (1067, 1141, 1203).

Four copies of the same refusal wording and nil-guard. The comment here even says it is worded as core_mcpserver_*'s, so the wording is kept in sync by hand. A shared helper in internal/api (for example ValidateTagsUpdate(*[]string) error plus one error-formatting function) lets both adapters call it.

Comment thread internal/api/tags.go

// TagsArgDescription describes the tags argument of the MCPServer and
// Workflow mutation tools.
const TagsArgDescription = "Up to 50 unique tags (ui.giantswarm.io/tags): a-z0-9 words joined by single hyphens, max 63 chars. Update: omitted keeps, [] clears, a list replaces"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TagsArgDescription, used on create and validate as well as update, describes update semantics (Update: omitted keeps, [] clears, a list replaces) that don't apply to create or validate.

A client or LLM reading core_workflow_create's or core_mcpserver_validate's schema sees replace/keep/clear semantics that mean nothing there. Use a create/validate description and an update description, the way the displayName arg distinguishes on update.

Comment thread internal/workflow/api_adapter.go Outdated
wf.Description = desc
}

tags, err := convertTags(args[fieldTags])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

CLAUDE.md (repo root) says File size: Keep under 400 lines, refactor larger files, and this PR adds more code to internal/workflow/api_adapter.go (already 2883 lines) and internal/mcpserver/api_adapter.go (1486 lines).

Rule in CLAUDE.md, Conventions: **File size**: Keep under 400 lines, refactor larger files. The PR adds about 27 lines to the 2883-line workflow adapter (handler guards, list field, convertToWorkflow tags) and about 19 to the mcpserver adapter. The helpers went to tags.go, but the handler wiring could also sit behind one call per handler to keep the large files from growing.

}

// tooManyTags returns api.MaxTags+1 distinct valid tags as a tool argument.
func tooManyTags() []interface{} {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tooManyTags is copied verbatim in internal/mcpserver/tags_test.go:179, and numberedTags in internal/api/tags_test.go builds the same list.

Three copies of the same test fixture across packages. A change to MaxTags semantics or tag format has to touch all three. A small exported test helper (or reusing numberedTags through an internal testutil package) avoids the duplication.

api.TagsArg declares the tags argument for core_mcpserver_* and
core_workflow_* with pattern, maxLength, maxItems and uniqueItems in its
JSON Schema. api.InvalidTagsResult is the single refusal for create,
update and validate. Workflow create and update take the tags parsed by
ParseRequest instead of re-decoding them from the raw arguments.

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

LGTM. I pushed two commits: one tags argument definition with the format rules in its JSON Schema, plus a shared refusal helper (convertTags and workflow/tags.go are gone), and CHANGELOG notes for the workflow list TAGS column. Not changed: a hand-set list of more than 50 tags is refused on edit, not trimmed.

This branch has not been deployed

No deployments
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.

3 participants