Files
Sam Morrow 8ec62491c6 Add confirmed repository deletion tool (#3076)
* feat(repos): add confirmed repository deletion

Add a destructive delete_repository tool that requires an exact owner/repo confirmation through multi-round-trip elicitation. Gate the tool to MCP protocol 2026-07-28 and newer across local and remote transports.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* refactor(inventory): generalize tool availability guards

Gate protocol-restricted tools on required elicitation capabilities and enforce direct calls inside the registered handler so SDK result finalization remains intact.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* feat(http): protect MRTR request state

Seal repository deletion targets for self-hosted HTTP with a stable AES-256-GCM key. Hide only delete_repository when no key is configured and expose an optional sealer interface for remote integrators.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* fix(repos): expire deletion confirmations

Bind sealed repository deletion state to the immutable repository ID and a ten-minute expiry. Re-check identity before deletion so replay cannot affect a recreated repository.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* fix(http): preserve tool and scope restrictions

Apply static allowlists before removing unavailable tools and fail closed on invalid configured tool names. Model independent OAuth requirements as conjunctive groups so repository deletion requires both delete_repo and repo.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* fix(repos): require protected confirmation state

Give stdio a process-local request-state sealer and make deletion fail closed without one. Preserve legacy any-of OAuth behavior globally while documenting and enforcing delete_repository's conjunctive delete_repo and repo requirements.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* fix(oauth): request repository deletion scope

Include delete_repo in the supported OAuth scope set used by stdio login, HTTP protected-resource metadata, and tool filtering.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* fix(oauth): require deletion scope opt-in

Keep delete_repo in protected-resource discovery for step-up authorization while excluding it from the default stdio OAuth grant.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* refactor(oauth): derive scope sets from catalog

Generate protected-resource supported scopes and the lower-risk default OAuth grant from one canonical scope definition list.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* refactor(scopes): own OAuth scope catalog

Move supported and default OAuth scope policy into pkg/scopes so protected-resource metadata and stdio grants derive from the scope domain package.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

* fix(scopes): require workflow scope opt-in

Keep workflow and codespace in protected-resource discovery while excluding both from the default OAuth grant alongside delete_repo.

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

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8

---------

Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
2026-08-18 14:50:38 +02:00

150 lines
4.6 KiB
Go

package inventory
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// ProtocolVersionMultiRoundTrip is the first MCP protocol version that supports
// multi-round-trip input requests.
const ProtocolVersionMultiRoundTrip = "2026-07-28"
// ElicitationMode identifies a client-supported elicitation interaction mode.
type ElicitationMode string
const (
// ElicitationModeForm collects structured user input through the client.
ElicitationModeForm ElicitationMode = "form"
// ElicitationModeURL directs the user to an external URL.
ElicitationModeURL ElicitationMode = "url"
)
type toolAvailability struct {
minimumProtocolVersion string
requiredElicitationMode ElicitationMode
}
func (st *ServerTool) availability() toolAvailability {
return toolAvailability{
minimumProtocolVersion: st.MinimumProtocolVersion,
requiredElicitationMode: st.RequiredElicitationMode,
}
}
func (a toolAvailability) unrestricted() bool {
return a.minimumProtocolVersion == "" && a.requiredElicitationMode == ""
}
func addToolAvailabilityMiddleware(server *mcp.Server, tools []ServerTool) {
availabilityByName := make(map[string]toolAvailability)
for _, tool := range tools {
availability := tool.availability()
if availability.unrestricted() {
delete(availabilityByName, tool.Tool.Name)
} else {
// AddTool replaces an existing tool with the same name, so preserve
// the availability metadata from the last registered definition too.
availabilityByName[tool.Tool.Name] = availability
}
}
if len(availabilityByName) == 0 {
return
}
server.AddReceivingMiddleware(toolAvailabilityMiddleware(availabilityByName))
}
func toolAvailabilityMiddleware(availabilityByName map[string]toolAvailability) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
req, ok := request.(*mcp.ListToolsRequest)
if !ok {
return next(ctx, method, request)
}
result, err := next(ctx, method, request)
if err != nil {
return nil, err
}
list, ok := result.(*mcp.ListToolsResult)
if !ok {
return result, nil
}
tools := make([]*mcp.Tool, 0, len(list.Tools))
for _, tool := range list.Tools {
if toolAvailable(req.ProtocolVersion(), req.ClientCapabilities(), availabilityByName[tool.Name]) {
tools = append(tools, tool)
}
}
list.Tools = tools
return list, nil
}
}
}
func (st *ServerTool) wrapAvailabilityCheck(next mcp.ToolHandler) mcp.ToolHandler {
availability := st.availability()
if availability.unrestricted() {
return next
}
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if toolAvailable(req.ProtocolVersion(), req.ClientCapabilities(), availability) {
return next(ctx, req)
}
return toolUnavailableResult(st.Tool.Name, req, availability), nil
}
}
func toolAvailable(protocolVersion string, capabilities *mcp.ClientCapabilities, availability toolAvailability) bool {
return protocolVersionAllowed(protocolVersion, availability.minimumProtocolVersion) &&
elicitationModeSupported(capabilities, availability.requiredElicitationMode)
}
func protocolVersionAllowed(protocolVersion, minimum string) bool {
// MCP protocol versions use ISO dates, so lexical ordering is chronological.
return minimum == "" || protocolVersion >= minimum
}
func elicitationModeSupported(capabilities *mcp.ClientCapabilities, requiredMode ElicitationMode) bool {
if requiredMode == "" {
return true
}
if capabilities == nil || capabilities.Elicitation == nil {
return false
}
elicitation := capabilities.Elicitation
switch requiredMode {
case ElicitationModeForm:
// An empty elicitation capability means form-only for compatibility.
return elicitation.Form != nil || elicitation.URL == nil
case ElicitationModeURL:
return elicitation.URL != nil
default:
return false
}
}
func toolUnavailableResult(name string, req *mcp.CallToolRequest, availability toolAvailability) *mcp.CallToolResult {
message := fmt.Sprintf("Tool %q is unavailable for this client.", name)
switch {
case !protocolVersionAllowed(req.ProtocolVersion(), availability.minimumProtocolVersion):
message = fmt.Sprintf(
"Tool %q requires MCP protocol version %s or later.",
name,
availability.minimumProtocolVersion,
)
case !elicitationModeSupported(req.ClientCapabilities(), availability.requiredElicitationMode):
message = fmt.Sprintf(
"Tool %q requires client support for %s elicitation.",
name,
availability.requiredElicitationMode,
)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: message}},
IsError: true,
}
}