From 85a97ec0cfed1275d6ed7c8a50d9518a5a1de1bd Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Tue, 1 Sep 2026 22:18:55 +0000 Subject: [PATCH] fix(mcp): openWorldHint false for read-only and closed-workflow tools Directory validators require reads (readOnlyHint=true) to declare openWorldHint=false, and closed private workflows (download to local storage) likewise. Derive the compiled-catalog open-world hint from Safety instead of hardcoding true; auth_status keeps its platform-required open-world override. --- internal/mcp/annotations_wire_test.go | 5 ++++- internal/mcp/catalogsurface.go | 24 +++++++++++++-------- internal/mcp/core/transfer/download_file.go | 2 +- internal/mcp/vault/vault_get_file.go | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/mcp/annotations_wire_test.go b/internal/mcp/annotations_wire_test.go index 901779fc..f470b830 100644 --- a/internal/mcp/annotations_wire_test.go +++ b/internal/mcp/annotations_wire_test.go @@ -97,12 +97,15 @@ func TestAuthStatusAnnotationOverride(t *testing.T) { require.True(t, entry.Destructive, "auth_status must declare destructiveHint=true (a sent email cannot be unsent)") require.True(t, entry.OpenWorldHint, "auth_status classifies as open-world") - // A sibling read op without an override keeps the Safety mapping. + // A sibling read op without an override keeps the Safety mapping. Reads + // change no external state, so openWorldHint must stay false alongside + // readOnlyHint=true (directory validators reject readOnly+openWorld). entry = catalogDescriptorToEntry(catalog.ToolDescriptor{ Name: "pins_list", Safety: catalog.SafetyRead, }, nil, nil) require.True(t, entry.ReadOnly, "pins_list keeps SafetyRead -> readOnlyHint=true") + require.False(t, entry.OpenWorldHint, "pins_list is a read; openWorldHint must be false for read-only tools") } // requireSharedViewOrigin asserts every registered ui:// resource carries one diff --git a/internal/mcp/catalogsurface.go b/internal/mcp/catalogsurface.go index d3caea63..dd3b55c3 100644 --- a/internal/mcp/catalogsurface.go +++ b/internal/mcp/catalogsurface.go @@ -83,10 +83,13 @@ func compiledHandler(cat catalog.Catalog, name string, resolveToken func(ctx con // auth_status is the only one so far: it can trigger out-of-band sign-in // communication (the SSO hand-off emails the human a verification link, which // cannot be unsent), so the Claude/MCP directory validators classify it as -// non-read and destructive — a sent message is irreversible. Its hints must -// declare that contract rather than the local "reads config only" shape. -var readOnlyOverride = map[string]struct{ readOnly, destructive bool }{ - "auth_status": {readOnly: false, destructive: true}, +// non-read, destructive and open-world — a sent message is irreversible. Its +// hints must declare that contract rather than the local "reads config only" +// shape. +var readOnlyOverride = map[string]struct { + readOnly, destructive, openWorld bool +}{ + "auth_status": {readOnly: false, destructive: true, openWorld: true}, } // catalogDescriptorToEntry converts a compiler-produced catalog.ToolDescriptor @@ -98,19 +101,22 @@ var readOnlyOverride = map[string]struct{ readOnly, destructive bool }{ // SafetyDestructive -> Destructive=true // SafetyMutate -> neither // -// Every compiled operation talks to the Pinner service (or the IPFS/Sia -// networks), so the open-world hint defaults to true; the hints may be -// corrected per tool via readOnlyOverride where the platform contract -// demands it (see auth_status). +// The open-world hint derives from Safety: mutating/destructive operations +// change publicly visible internet state (pins, websites, DNS), while reads +// change nothing external, so openWorldHint stays false for any SafetyRead +// operation. The hints may be corrected per tool via readOnlyOverride where +// the platform contract demands it (see auth_status). // // DirectVisible is left to markCurated (the curated product surface), matching // how every other tool is promoted to tools/list. func catalogDescriptorToEntry(d catalog.ToolDescriptor, cat catalog.Catalog, resolveToken func(ctx context.Context) (string, error)) *model.ToolEntry { readOnly := d.Safety == catalog.SafetyRead destructive := d.Safety == catalog.SafetyDestructive + openWorld := !readOnly if override, ok := readOnlyOverride[d.Name]; ok { readOnly = override.readOnly destructive = override.destructive + openWorld = override.openWorld } entry := model.ToolEntryFromDescriptor(model.ToolDescriptor{ Name: d.Name, @@ -121,7 +127,7 @@ func catalogDescriptorToEntry(d catalog.ToolDescriptor, cat catalog.Catalog, res OutputSchema: outputSchemaForCompiled(d.Safety, d.Interaction), ReadOnly: readOnly, Destructive: destructive, - OpenWorldHint: true, + OpenWorldHint: openWorld, MCPTargets: toModelTargets(d.MCPTargets), Handler: compiledHandler(cat, d.Name, resolveToken), }) diff --git a/internal/mcp/core/transfer/download_file.go b/internal/mcp/core/transfer/download_file.go index 58e447b7..51146e20 100644 --- a/internal/mcp/core/transfer/download_file.go +++ b/internal/mcp/core/transfer/download_file.go @@ -56,7 +56,7 @@ func NewDownloadFileDescriptor(ipfsFn IPFSDownloadHandler, hd *Download, downloa Title: "Download IPFS content to a file", Description: downloadFileDescription(hd != nil, tunnelOpenAI), Category: model.CategoryCore, - OpenWorldHint: true, // fetches content from the IPFS network + OpenWorldHint: false, // closed workflow: pulls content into local storage/filedrop; publishes nothing to the public internet // The input schema advertises only the sink values valid for the running // transport (drop only when a reachable HTTP mux exists on a non-OpenAI // tunnel), matching capabilities().download_sink_modes so the published diff --git a/internal/mcp/vault/vault_get_file.go b/internal/mcp/vault/vault_get_file.go index 0a582bdb..9075f3ec 100644 --- a/internal/mcp/vault/vault_get_file.go +++ b/internal/mcp/vault/vault_get_file.go @@ -53,7 +53,7 @@ func NewVaultGetFileDescriptor(getFn transfer.VaultGetHandler, hd *transfer.Down Title: "Download a file from the Pinner vault", Description: vaultGetFileDescription(hd != nil, tunnelOpenAI), Category: model.CategoryCore, - OpenWorldHint: true, // may fetch file bytes from the Sia network + OpenWorldHint: false, // closed workflow: pulls the caller's vault bytes to a local sink/filedrop; publishes nothing // The input schema advertises only the sink values valid for the running // server (drop only when a reachable HTTP mux exists on a non-OpenAI // tunnel), matching capabilities().download_sink_modes.