Support MCP tool annotations for composite tools - #6208
Conversation
Composite tools never wired into the annotation path, so clients that gate confirmation prompts or filter destructive actions had no signal for them (readOnlyHint, destructiveHint, idempotentHint, openWorldHint). Add an optional Annotations field to CompositeToolConfig (reusing ToolAnnotationsOverride), carried through WorkflowDefinition into ConvertWorkflowDefsToTools. When unset, a conservative safety floor is derived from the workflow's step tools: readOnlyHint is AND across steps, destructiveHint and openWorldHint are OR (an unknown step taints), and idempotentHint is never derived. The derivation fails closed: a workflow with tool steps whose backends declare no annotations yields a conservative floor rather than none, so the contradiction guardrail still fires. An explicit annotation that contradicts the floor (looks safer than warranted) drops the tool at advertise time with a warning naming the offending steps; a more-conservative explicit value is allowed. Extract a shared router.ResolveToolRef for the workload.tool step-ref resolution previously duplicated between isToolStepAccessible and the new stepAnnotationResolver. Regenerate CRDs and deepcopy, and update the composite tool docs and examples. Closes #6192
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6208 +/- ##
==========================================
+ Coverage 72.46% 72.55% +0.08%
==========================================
Files 739 741 +2
Lines 76719 76838 +119
==========================================
+ Hits 55594 55749 +155
+ Misses 17160 17108 -52
- Partials 3965 3981 +16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The sequential e2e asserts the stored composite tool spec via CompositeTools[0] but pinned the slice length to 1. The annotation coverage adds two more composite tools to the same server, so the length assertion failed. Assert the slice is non-empty instead; the per-field spec checks on CompositeTools[0] are the real contract.
amirejaz
left a comment
There was a problem hiding this comment.
Went through this properly. Two things I think need fixing before merge — I reproduced both with a throwaway test in pkg/vmcp/core (deleted afterwards), so they aren't theoretical.
1. A dropped composite is still executable
ConvertWorkflowDefsToTools drops the contradicting tool with the continue at workflow_converter.go:126-130, but that only filters what advertisedTools returns. accessibleComposites still returns defs untouched, and CallTool gates on accessibleComposites(agg)[name] (core_calls.go:56). So the guardrail hides the tool without withholding it.
Repro: workflow wf with readOnlyHint: true, one tool step against a backend tool that declares no annotations.
advertisedTools(agg)→[be1.echo];wfis dropped and the warning firesaccessibleComposites(agg)["wf"]→ present, soCallTool("wf")executes the workflow
Two comments assert the opposite and need updating either way: core_vmcp.go:654-656 ("single source of truth shared by advertisedTools and CallTool, so a withheld composite is never executed — advertised equals executed") and core_calls.go:52-55.
Knock-on effect: authorizeToolCall resolves the tool via findAdvertisedTool(c.advertisedTools(agg), name), so for a dropped composite it falls back to &vmcp.Tool{Name: name}. An annotation-gated admission policy then evaluates with no hints at all, on precisely the tool the guardrail flagged as unsafe.
2. The noop resolver lets genuine name conflicts through
accessibleComposites passes noopResolver (core_vmcp.go:670-676) on the basis that annotations are irrelevant to name collisions. But the noop resolver forces every step to "unknown", so the floor is always the conservative one, so any composite carrying a safety-claiming explicit hint gets dropped from the input to ValidateNoToolConflicts — including ones the real resolver would have allowed. A dropped composite can't be detected as conflicting.
Repro: composite named be1.echo (same name as the backend tool) with readOnlyHint: true survives the conflict check. accessibleComposites returns it, so a client calling the advertised backend tool be1.echo silently gets the workflow instead. Pre-PR that collision was caught and all composites dropped, so this is a regression in the collision guard.
Both bugs have the same root cause: ConvertWorkflowDefsToTools now does conversion and policy, while the conflict check only ever needed names. The D4 note at workflow_converter.go:211-215 defers extracting a CompositeToolNames primitive as low priority — it's actually load-bearing here. Extracting it fixes (2); applying the annotation guard inside accessibleComposites so it filters defs fixes (1) and restores the advertised-equals-executed invariant.
On drop-vs-clamp
Separate from the bugs, worth settling before this lands. The docs are candid enough about the drop semantics that they end up steering people away from the feature: the quick reference says "prefer leaving readOnlyHint unset", and the guide explains that with silent backends readOnlyHint: true "will contradict it and be dropped". Which means the motivating case in #6192 — a read-only report generator — can't be expressed against today's backends.
#6192 asked for rejection at config load/admission. The argument for moving it to runtime is sound (step refs aren't resolvable pre-aggregation), but drop is a separate choice on top of that, and it's the least forgiving one: silent, log-only, invisible to thv vmcp validate, and adding an annotation makes your tool disappear. Clamping instead — advertise the more conservative of explicit and floor, and warn — is equally safe for clients (nothing safety-lowering reaches them), keeps the tool working, and makes bug (1) moot because nothing is hidden in the first place. If drop stays, I think it needs a status condition rather than just a slog.Warn.
Related: the guide's headline example at virtualmcpcompositetooldefinition-guide.md:355-363 is readOnlyHint: true over step users.get, which the same document later explains would be dropped.
Smaller things
- The dedup is only half done.
router.ResolveToolRef(session_router.go:91) was extracted, butRouteTool(:132) andResolveToolName(:165) still carry byte-identical copies of the same resolution — three copies before, three after.ResolveToolNamereduces toif n, ok := ResolveToolRef(...); ok { return n }; return toolName. - The new e2e spec asserts
echo_contradictingis absent fromtools/listbut not that it's uncallable. ACallToolon it expecting a failure is the assertion that would have caught bug (1). - The nil-floor-hint branches in
CheckAnnotationContradiction(annotations.go:102,108,114) are unreachable for any floor fromDeriveCompositeAnnotations, which always populates all three hints — so the doc paragraph above them describes a case that can't happen. - Every existing composite tool now advertises
destructiveHint: true, openWorldHint: truewhere it previously advertised nothing. That matches the MCP spec defaults so conformant clients are unaffected, but clients that branch onannotations != nilwill start badging composites destructive. Probably worth a line in the user-facing change section. ToolAnnotationsOverride's field docs all read "overrides the X annotation", which reads oddly for composites where they're declarations rather than overrides — and it shows up verbatim in the generated CRD schema andcrd-api.md.
For what it's worth, reusing ToolAnnotationsOverride instead of adding a third annotations struct is the right call, and the AND/OR/never-derive truth table matches #6192 exactly. Unit coverage of the pure functions is solid.
Summary
MCP clients use tool annotations (
readOnlyHint,destructiveHint,idempotentHint,openWorldHint) to gate confirmation prompts and filter destructive actions. Composite tools never wired into the annotation path —ConvertWorkflowDefsToToolsbuilt eachvmcp.ToolwithAnnotationsleft nil — so clients had no signal for composite tools even when the workflow author knew the semantics (e.g. a read-only report generator, or a workflow that deletes resources). Backend/aggregated tools already supported annotation overrides viaToolAnnotationsOverride; composite tools were the gap.What changed:
Annotationsfield toCompositeToolConfig(reusing the existingToolAnnotationsOverridetype rather than introducing a third parallel annotations struct). It flows throughcomposer.WorkflowDefinitionintoConvertWorkflowDefsToTools, which now populatesvmcp.Tool.Annotations. Because the CRD embedsCompositeToolConfiginline, the field appears automatically in bothVirtualMCPCompositeToolDefinitionandVirtualMCPServerschemas.readOnlyHintis AND across steps,destructiveHint/openWorldHintare OR (an unknown/undeclared step taints them), andidempotentHintis never derived. The derivation fails closed: a workflow whose tool steps declare no annotations yields a conservative floor (not read-only, destructive, open-world) rather than no floor, so the guardrail still fires when backends are silent — the common case today.readOnlyHint: truewhile a step is destructive or undeclared) causes the tool to be dropped at advertise time with a warning naming the offending steps. A more-conservative explicit value (e.g.readOnlyHint: falseon a read-only floor) is allowed.router.ResolveToolRefto hold theworkload.toolstep-ref resolution that was duplicated betweenisToolStepAccessibleand the newstepAnnotationResolver(also fixes a latent nil-deref on a nil routing table).Closes #6192
Type of change
Test plan
task test)task lint-fix)Unit tests cover the derivation truth tables (AND/OR/never-derived, fail-closed), the contradiction guardrail (each contradiction type + the allowed more-conservative case + nil-floor-hint guards), the merge semantics, the dotted-name resolver, and the conversion wiring, plus a YAML round-trip test guarding the json/yaml tag requirement. E2E specs were added under
test/e2e/thv-operator/virtualmcp/asserting derived annotations viaListTools, explicit pass-through, and drop-on-contradiction-while-Ready — these require a cluster and were written but not executed here (noted below).API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.The change adds a single optional field (
annotations) toCompositeToolConfig; it is not required, has no default that alters existing behavior, and no CEL rule that existing valid objects would violate. Fully backward-compatible.Changes
pkg/vmcp/config/config.goAnnotations *ToolAnnotationsOverridetoCompositeToolConfigpkg/vmcp/config/annotations.goToAnnotations()converter onToolAnnotationsOverridepkg/vmcp/composer/composer.goAnnotationsonWorkflowDefinitionpkg/vmcp/server/workflow_converter.gopkg/vmcp/internal/compositetools/annotations.gopkg/vmcp/internal/compositetools/workflow_converter.govmcp.Tool.Annotations; drop+warn on contradictionpkg/vmcp/router/session_router.goResolveToolRefstep-ref resolutionpkg/vmcp/core/core_vmcp.gozz_generated.deepcopy.go+crd-api.mdDoes this introduce a user-facing change?
Yes. Workflow authors can now set
annotationson a composite tool (inline inVirtualMCPServer, in aVirtualMCPCompositeToolDefinition, or in CLI vMCP config YAML). When omitted, safe defaults are derived from step tools. Note:thv vmcp validateperforms structural validation only and does not check annotation contradictions — a contradictory annotation is surfaced at runtime (the tool is dropped and a warning is logged in vMCP), not at admission.Implementation plan
Approved implementation plan
Reuse
*config.ToolAnnotationsOverrideas the field type (json+yaml tags, generated DeepCopy, controller-gen-covered) to avoid a third parallel annotations struct —*vmcp.ToolAnnotationswas rejected (no yaml tags → breaks the operatoryaml.Marshalpath; no DeepCopy;pkg/vmcproot is outside controller-gen paths).Flow:
CompositeToolConfig.Annotations→composer.WorkflowDefinition.Annotations→ three pure functions in a newcompositetools/annotations.go(DeriveCompositeAnnotations,CheckAnnotationContradiction,MergeAnnotations), wired intoConvertWorkflowDefsToToolsvia an injectedStepAnnotationResolverbuilt at the one place backend annotations exist (core_vmcp.goadvertisedTools).accessibleCompositesuses a noop resolver (annotations are irrelevant to name-conflict detection).Operator-approved decisions: (Q1) the floor-contradiction guardrail runs at runtime/advertise time with drop+warn posture — it cannot run at config load because step-tool refs are format-checked strings never resolved to backends pre-runtime;
validateand the operator reconcile stay structural-only. (Q2) more-conservative explicit annotations are allowed. (Q3) full e2e through the operator Ginkgo harness.A post-implementation review panel (spec / standards / security / architecture / duplication / library-reuse / devex / ux / QA) plus follow-up investigation surfaced and fixed: the fail-closed derivation defect (floor was nil when backends declared no annotations, bypassing the guardrail), a self-contradicting example, the duplicated step-ref resolution (extracted
router.ResolveToolRef), pointer-aliasing in merge, and several UX/doc and test-adequacy gaps.Special notes for reviewers
readOnlyHint=false,destructiveHint=true,openWorldHint=true), not a nil floor — otherwise the contradiction guardrail is silently bypassed whenever backends are silent (the common case today, e.g. the yardstickechotool declares no annotations). A workflow with no tool steps (e.g. elicitation-only) still yields a nil floor. This is the behavior worth the closest look.agg.Toolsis materialized.thv vmcp validateand the operator therefore cannot detect contradictions; this limitation is disclosed in the docs.go vetclean) and follow existing file patterns, but have not run against a live cluster; the e2e CI job should exercise them.VirtualMCPCompositeToolDefinitionStatus.ValidationStatusis never written by any reconciler (pre-existing dead-status gap). A design for a small dedicated reconciler exists and is intentionally deferred to a separate PR — even with a writer, only structural validation can be surfaced there, never the runtime floor check.