Files
Sam Morrow 0503f2f2c5 fix(mcp-apps): reconcile the show/defer contract — render results, remove show_ui (#2774)
* fix(ui): render success view when an MCP App tool executed up-front

The create_pull_request / issue_write / update_pull_request Views decided
form-vs-success from in-app submit state only, ignoring the tool-result the
host delivers on render. Per the MCP Apps 2026-01-26 spec the host renders a
View whenever the tool carries _meta.ui.resourceUri — independent of whether
the server deferred or executed. So when the server executed up-front (e.g.
show_ui=false, or parameters the form can't represent) the View still showed
its "Create pull request" input form over an already-created PR, which reads
as a bug (it even shows a PR number).

Drive the Views off the result instead: a new shared completedToolResult()
helper returns parsed data only for a genuine completed success, and returns
null for the awaiting_user_submission deferral sentinel, errors, or no result.
Each write View now shows its success card when that completed result is
present, so the form is only ever shown while the action is genuinely deferred.

Reconciles the show/defer state machine at the View (decision layer that the
host result feeds). See github/copilot-mcp-core#1864.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ui): scope tool-result to the current invocation

Address review feedback: the write Views derive their success card from
`toolResult`, but it wasn't cleared when a new invocation arrived (only the
in-app `successPR`/`successIssue` was reset on `toolInput` change). A completed
result from a previous invocation could briefly render a stale success card
over the next, still-deferred form.

Clear `toolResult` whenever a new `tool-input` notification arrives. The spec
guarantees `tool-input` precedes that invocation's `tool-result`, so this scopes
the result to the current invocation centrally in the hook — fixing all three
Views without per-app invocation keys.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(mcp-apps): remove show_ui — it can't suppress app rendering

show_ui promised "skip the form and execute directly", but it can't deliver:
the host renders an MCP App for any tool that carries _meta.ui.resourceUri, and
the 2026-01-26 MCP Apps spec has no per-call/per-result way to opt out of
rendering. show_ui only flipped the server's defer decision, so show_ui=false
created the PR/issue up-front yet the host still rendered the app — exactly the
contradiction this work set out to fix. And show_ui is only ever exposed to
clients that support UI, i.e. precisely the clients that always render the app.

Remove it entirely:
- Drop the show_ui schema property, the form-param allowlist entry, and the
  showUI term from the defer predicate in create_pull_request and issue_write.
  The gate is now FF && clientSupportsUI && !_ui_submitted && !hasNonFormParams.
- Delete the now-unused UI-only schema-property strip machinery in
  pkg/inventory (uiOnlySchemaProperties, stripUIOnlySchemaProperties,
  stripSchemaProperties) and the exported ConditionalSchemaPropertyDescriptions,
  which existed solely to surface show_ui to UI-capable clients. _meta.ui
  stripping is untouched.
- Drop the conditional-property annotation from the docs generator.
- Update toolsnaps, generated docs, and tests.

With the up-front-execution Views now rendering the result (success card), the
remaining contract is simple: when MCP Apps are enabled the form is the path,
and the form is only shown while the action is genuinely deferred.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(mcp-apps): centralize the show/defer decision (single source of truth)

The defer-to-form predicate was triplicated across create_pull_request,
update_pull_request, and issue_write, each with its own near-identical
*HasNonFormParams function. As more MCP App tools are added this duplication
would grow and the copies could silently drift.

Extract one shared gate in ui_capability.go:
- shouldDeferToForm(ctx, deps, req, args, formParams) — the single show/defer
  decision (MCP Apps enabled, client supports UI, not a form submission, and no
  non-form params).
- hasNonFormParams(args, formParams) — one generic helper replacing the three
  per-tool functions.
- uiSubmitted(args) — small shared predicate.

Each handler is now a one-line `if shouldDeferToForm(...) { return awaiting }`.
The per-tool form-parameter allowlists and the user-facing messages stay
per-tool (that is the genuine per-tool config). Pure refactor — behavior
unchanged; existing tests now exercise the generic helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 15:28:29 +02:00

444 lines
14 KiB
Go

package inventory
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
)
var (
// ErrUnknownTools is returned when tools specified via WithTools() are not recognized.
ErrUnknownTools = errors.New("unknown tools specified in WithTools")
)
// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata.
// This is defined here to avoid importing pkg/github (which imports pkg/inventory).
// The value must match github.MCPAppsFeatureFlag.
const mcpAppsFeatureFlag = "remote_mcp_ui_apps"
// ToolFilter is a function that determines if a tool should be included.
// Returns true if the tool should be included, false to exclude it.
type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error)
// Builder builds a Registry with the specified configuration.
// Use NewBuilder to create a builder, chain configuration methods,
// then call Build() to create the final inventory.
//
// Example:
//
// reg := NewBuilder().
// SetTools(tools).
// SetResources(resources).
// SetPrompts(prompts).
// WithDeprecatedAliases(aliases).
// WithReadOnly(true).
// WithToolsets([]string{"repos", "issues"}).
// WithFeatureChecker(checker).
// WithFilter(myFilter).
// Build()
type Builder struct {
tools []ServerTool
resourceTemplates []ServerResourceTemplate
prompts []ServerPrompt
deprecatedAliases map[string]string
// Configuration options (processed at Build time)
readOnly bool
toolsetIDs []string // raw input, processed at Build()
toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults)
additionalTools []string // raw input, processed at Build()
featureChecker FeatureFlagChecker
filters []ToolFilter // filters to apply to all tools
generateInstructions bool
}
// NewBuilder creates a new Builder.
func NewBuilder() *Builder {
return &Builder{
deprecatedAliases: make(map[string]string),
toolsetIDsIsNil: true, // default to nil (use defaults)
}
}
// SetTools sets the tools for the inventory. Returns self for chaining.
func (b *Builder) SetTools(tools []ServerTool) *Builder {
b.tools = tools
return b
}
// SetResources sets the resource templates for the inventory. Returns self for chaining.
func (b *Builder) SetResources(resources []ServerResourceTemplate) *Builder {
b.resourceTemplates = resources
return b
}
// SetPrompts sets the prompts for the inventory. Returns self for chaining.
func (b *Builder) SetPrompts(prompts []ServerPrompt) *Builder {
b.prompts = prompts
return b
}
// WithDeprecatedAliases adds deprecated tool name aliases that map to canonical names.
// Returns self for chaining.
func (b *Builder) WithDeprecatedAliases(aliases map[string]string) *Builder {
maps.Copy(b.deprecatedAliases, aliases)
return b
}
// WithReadOnly sets whether only read-only tools should be available.
// When true, write tools are filtered out. Returns self for chaining.
func (b *Builder) WithReadOnly(readOnly bool) *Builder {
b.readOnly = readOnly
return b
}
func (b *Builder) WithServerInstructions() *Builder {
b.generateInstructions = true
return b
}
// WithToolsets specifies which toolsets should be enabled.
// Special keywords:
// - "all": enables all toolsets
// - "default": expands to toolsets marked with Default: true in their metadata
//
// Input strings are trimmed of whitespace and duplicates are removed.
// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets.
// Returns self for chaining.
func (b *Builder) WithToolsets(toolsetIDs []string) *Builder {
b.toolsetIDs = toolsetIDs
b.toolsetIDsIsNil = toolsetIDs == nil
return b
}
// WithTools specifies additional tools that bypass toolset filtering.
// These tools are additive - they will be included even if their toolset is not enabled.
// Read-only filtering still applies to these tools.
// Input is cleaned (trimmed, deduplicated) during Build().
// Deprecated tool aliases are automatically resolved to their canonical names during Build().
// Returns self for chaining.
func (b *Builder) WithTools(toolNames []string) *Builder {
b.additionalTools = toolNames
return b
}
// WithFeatureChecker sets the feature flag checker function.
// The checker receives a context (for actor extraction) and feature flag name,
// and returns (enabled, error). Errors are logged and treated as "not enabled".
//
// When the checker is non-nil, Build() installs a feature-flag ToolFilter
// at the head of the filter pipeline so that tools annotated with
// FeatureFlagEnable / FeatureFlagDisable are gated accordingly. Resources
// and prompts use the same checker via an explicit guard at their iteration
// site.
//
// When the checker is nil, no feature-flag filter is installed; tools,
// resources, and prompts pass through feature-flag gating unchanged. The
// per-request inventory in HTTP mode must always install a checker so that
// MCP registration (which can only serve a given tool name once) sees a
// deduplicated set of dual-name variants.
//
// Returns self for chaining.
func (b *Builder) WithFeatureChecker(checker FeatureFlagChecker) *Builder {
b.featureChecker = checker
return b
}
// WithFilter adds a filter function that will be applied to all tools.
// Multiple filters can be added and are evaluated in order.
// If any filter returns false or an error, the tool is excluded.
// Returns self for chaining.
func (b *Builder) WithFilter(filter ToolFilter) *Builder {
b.filters = append(b.filters, filter)
return b
}
// WithExcludeTools specifies tools that should be disabled regardless of other settings.
// These tools will be excluded even if their toolset is enabled or they are in the
// additional tools list. This takes precedence over all other tool enablement settings.
// Input is cleaned (trimmed, deduplicated) before applying.
// Returns self for chaining.
func (b *Builder) WithExcludeTools(toolNames []string) *Builder {
cleaned := cleanTools(toolNames)
if len(cleaned) > 0 {
b.filters = append(b.filters, CreateExcludeToolsFilter(cleaned))
}
return b
}
// CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name.
// Any tool whose name appears in the excluded list will be filtered out.
// The input slice should already be cleaned (trimmed, deduplicated).
func CreateExcludeToolsFilter(excluded []string) ToolFilter {
set := make(map[string]struct{}, len(excluded))
for _, name := range excluded {
set[name] = struct{}{}
}
return func(_ context.Context, tool *ServerTool) (bool, error) {
_, blocked := set[tool.Tool.Name]
return !blocked, nil
}
}
// cleanTools trims whitespace and removes duplicates from tool names.
// Empty strings after trimming are excluded.
func cleanTools(tools []string) []string {
seen := make(map[string]bool)
var cleaned []string
for _, name := range tools {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if !seen[trimmed] {
seen[trimmed] = true
cleaned = append(cleaned, trimmed)
}
}
return cleaned
}
// Build creates the final Inventory with all configuration applied.
// This processes toolset filtering, tool name resolution, and sets up
// the inventory for use. The returned Inventory is ready for use with
// AvailableTools(), RegisterAll(), etc.
//
// Build returns an error if any tools specified via WithTools() are not recognized
// (i.e., they don't exist in the tool set and are not deprecated aliases).
// This ensures invalid tool configurations fail fast at build time.
func (b *Builder) Build() (*Inventory, error) {
tools := b.tools
// Install the feature-flag filter at the head of the pipeline so that
// flag-gated tools are excluded before any user-supplied WithFilter sees
// them. Doing this in Build() (rather than inside WithFeatureChecker)
// keeps the install idempotent — repeated WithFeatureChecker calls
// replace the checker without stacking duplicate filters.
filters := b.filters
if b.featureChecker != nil {
filters = append([]ToolFilter{createFeatureFlagFilter(b.featureChecker)}, filters...)
}
r := &Inventory{
tools: tools,
resourceTemplates: b.resourceTemplates,
prompts: b.prompts,
deprecatedAliases: b.deprecatedAliases,
readOnly: b.readOnly,
featureChecker: b.featureChecker,
filters: filters,
}
// Process toolsets and pre-compute metadata in a single pass
r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets()
// Build set of valid tool names for validation
validToolNames := make(map[string]bool, len(tools))
for i := range tools {
validToolNames[tools[i].Tool.Name] = true
}
// Process additional tools (clean, resolve aliases, and track unrecognized)
if len(b.additionalTools) > 0 {
cleanedTools := cleanTools(b.additionalTools)
r.additionalTools = make(map[string]bool, len(cleanedTools))
var unrecognizedTools []string
for _, name := range cleanedTools {
// Always include the original name - this handles the case where
// the tool exists but is controlled by a feature flag that's OFF.
r.additionalTools[name] = true
// Also include the canonical name if this is a deprecated alias.
// This handles the case where the feature flag is ON and only
// the new consolidated tool is available.
if canonical, isAlias := b.deprecatedAliases[name]; isAlias {
r.additionalTools[canonical] = true
} else if !validToolNames[name] {
// Not a valid tool and not a deprecated alias - track as unrecognized
unrecognizedTools = append(unrecognizedTools, name)
}
}
// Error out if there are unrecognized tools
if len(unrecognizedTools) > 0 {
return nil, fmt.Errorf("%w: %s", ErrUnknownTools, strings.Join(unrecognizedTools, ", "))
}
}
if b.generateInstructions {
r.instructions = generateInstructions(r)
}
return r, nil
}
// processToolsets processes the toolsetIDs configuration and returns:
// - enabledToolsets map (nil means all enabled)
// - unrecognizedToolsets list for warnings
// - allToolsetIDs sorted list of all toolset IDs
// - toolsetIDSet map for O(1) HasToolset lookup
// - defaultToolsetIDs sorted list of default toolset IDs
// - toolsetDescriptions map of toolset ID to description
func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, map[ToolsetID]bool, []ToolsetID, map[ToolsetID]string) {
// Single pass: collect all toolset metadata together
validIDs := make(map[ToolsetID]bool)
defaultIDs := make(map[ToolsetID]bool)
descriptions := make(map[ToolsetID]string)
for i := range b.tools {
t := &b.tools[i]
validIDs[t.Toolset.ID] = true
if t.Toolset.Default {
defaultIDs[t.Toolset.ID] = true
}
if t.Toolset.Description != "" {
descriptions[t.Toolset.ID] = t.Toolset.Description
}
}
for i := range b.resourceTemplates {
r := &b.resourceTemplates[i]
validIDs[r.Toolset.ID] = true
if r.Toolset.Default {
defaultIDs[r.Toolset.ID] = true
}
if r.Toolset.Description != "" {
descriptions[r.Toolset.ID] = r.Toolset.Description
}
}
for i := range b.prompts {
p := &b.prompts[i]
validIDs[p.Toolset.ID] = true
if p.Toolset.Default {
defaultIDs[p.Toolset.ID] = true
}
if p.Toolset.Description != "" {
descriptions[p.Toolset.ID] = p.Toolset.Description
}
}
// Build sorted slices from the collected maps
allToolsetIDs := make([]ToolsetID, 0, len(validIDs))
for id := range validIDs {
allToolsetIDs = append(allToolsetIDs, id)
}
slices.Sort(allToolsetIDs)
defaultToolsetIDList := make([]ToolsetID, 0, len(defaultIDs))
for id := range defaultIDs {
defaultToolsetIDList = append(defaultToolsetIDList, id)
}
slices.Sort(defaultToolsetIDList)
toolsetIDs := b.toolsetIDs
// Check for "all" keyword - enables all toolsets
for _, id := range toolsetIDs {
if strings.TrimSpace(id) == "all" {
return nil, nil, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions // nil means all enabled
}
}
// nil means use defaults, empty slice means no toolsets
if b.toolsetIDsIsNil {
toolsetIDs = []string{"default"}
}
// Expand "default" keyword, trim whitespace, collect other IDs, and track unrecognized
seen := make(map[ToolsetID]bool)
expanded := make([]ToolsetID, 0, len(toolsetIDs))
var unrecognized []string
for _, id := range toolsetIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if trimmed == "default" {
for _, defaultID := range defaultToolsetIDList {
if !seen[defaultID] {
seen[defaultID] = true
expanded = append(expanded, defaultID)
}
}
} else {
tsID := ToolsetID(trimmed)
if !seen[tsID] {
seen[tsID] = true
expanded = append(expanded, tsID)
// Track if this toolset doesn't exist
if !validIDs[tsID] {
unrecognized = append(unrecognized, trimmed)
}
}
}
}
if len(expanded) == 0 {
return make(map[ToolsetID]bool), unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions
}
enabledToolsets := make(map[ToolsetID]bool, len(expanded))
for _, id := range expanded {
enabledToolsets[id] = true
}
return enabledToolsets, unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions
}
// mcpAppsMetaKeys lists the Meta keys controlled by the remote_mcp_ui_apps feature flag.
var mcpAppsMetaKeys = []string{
"ui", // MCP Apps UI metadata
}
// stripMCPAppsMetadata removes MCP Apps UI metadata from tools when the
// remote_mcp_ui_apps feature flag is not enabled.
func stripMCPAppsMetadata(tools []ServerTool) []ServerTool {
result := make([]ServerTool, 0, len(tools))
for _, tool := range tools {
if stripped := stripMetaKeys(tool, mcpAppsMetaKeys); stripped != nil {
result = append(result, *stripped)
} else {
result = append(result, tool)
}
}
return result
}
// stripMetaKeys removes the specified Meta keys from a single tool.
// Returns a modified copy if changes were made, nil otherwise.
func stripMetaKeys(tool ServerTool, keys []string) *ServerTool {
if tool.Tool.Meta == nil || len(keys) == 0 {
return nil
}
// Check if any of the specified keys exist
hasKeys := false
for _, key := range keys {
if _, ok := tool.Tool.Meta[key]; ok {
hasKeys = true
break
}
}
if !hasKeys {
return nil
}
// Make a shallow copy and remove specified keys
toolCopy := tool
newMeta := make(map[string]any, len(tool.Tool.Meta))
for k, v := range tool.Tool.Meta {
if !slices.Contains(keys, k) {
newMeta[k] = v
}
}
if len(newMeta) == 0 {
toolCopy.Tool.Meta = nil
} else {
toolCopy.Tool.Meta = newMeta
}
return &toolCopy
}