Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ tests/sunpeak/ MCP integration tests (driver `pinner mcp` over stdi
- `catalog.go` — `ToolCatalog`: a two-tier tool surface. Curated, most-used
tools are listed directly in `tools/list`; the rest of the catalog is
served through progressive disclosure (`search_tools` → `describe_tool` →
`invoke_tool`).
the typed invoke dispatchers `invoke_read_tool` / `invoke_write_tool` /
`invoke_destructive_tool`, split by safety class so each dispatcher's MCP
annotations are truthful for platform directory validation).
- `hostenv/` + `toolforge/` — the MCP surface is host-aware: tool
descriptions, schemas, and variants are resolved against the connected host
profile (platform/transport/auth) via feature gating (`hostenv.Feature`
Expand Down Expand Up @@ -341,7 +343,7 @@ refused (human confirms via hand-off), and the handler additionally rejects
**MCP surface:** `ens_point` / `ens_unpoint` are single-level catalog ops
(category `ens`) and are **not** in `compiledCuratedToolNames`, so they stay
behind progressive disclosure (`search_tools {query:"ens"}` →
`describe_tool` → `invoke_tool`) and never bloat `tools/list`. The
`describe_tool` → the typed invoke dispatchers) and never bloat `tools/list`. The
`agent_guide` `ens_publish` flow and the `ens-publish` prompt
(`internal/mcp/prompttemplates/ens_publish.tmpl`) steer an agent to them.

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ When running under an MCP client, the server exposes three meta-tools for progre
|-----------|---------|
| `search_tools` | Search the internal tool catalog by keyword or category (`core`, `admin`, `wizard`) |
| `describe_tool` | Get the full JSON Schema input definition for a specific tool |
| `invoke_tool` | Execute a tool by name with arguments |
| `invoke_read_tool` / `invoke_write_tool` / `invoke_destructive_tool` | Execute a tool by name with arguments; the dispatchers are split by safety class (read-only / mutating / destructive) and `describe_tool` names the right one |

**Wizard tools** (website onboarding, setup) are also available through the meta-tools. They use FSM-based sessions with a 30-minute TTL and a 100-session limit.

Expand Down Expand Up @@ -900,7 +900,7 @@ pinner-cli/
├── pkg/internal/mcp/ # MCP adapter (MCPCommand, sessions, resources, prompts)
│ ├── adapter.go # MCPCommand build and stdio serving
│ ├── catalog.go # Tool catalog with progressive disclosure
│ ├── meta_tools.go # search_tools, describe_tool, invoke_tool
│ ├── meta_tools.go # search_tools, describe_tool, typed invoke dispatchers
│ ├── session.go # FSM-based wizard session store
│ ├── wizard.go # Website and setup wizard MCP tools
│ ├── resources.go # pinner:// resource handlers
Expand Down Expand Up @@ -934,7 +934,7 @@ pinner-cli/
| `BillingAdminService` | Admin credits, price lines, pricing plans, subscribers |
| `WebsiteAdminService` | Admin website block/unblock |

**MCP Adapter**: `pkg/internal/mcp/` adapts the urfave/cli command tree into an MCP server (`mcp.MCPCommand`). It exposes subcommands as MCP tools via progressive disclosure (only `search_tools`, `describe_tool`, `invoke_tool` are visible in `tools/list`). Tool invocation is in-process — no subprocess fork. The adapter injects `--agent` automatically for all invocations. Wizard sessions are FSM-based with TTL cleanup. Resources and prompts are registered via `ResourceProvidersFactory` and `WithPrompts()`.
**MCP Adapter**: `pkg/internal/mcp/` adapts the urfave/cli command tree into an MCP server (`mcp.MCPCommand`). It exposes subcommands as MCP tools via progressive disclosure (only `search_tools`, `describe_tool`, and the typed invoke dispatchers `invoke_read_tool`/`invoke_write_tool`/`invoke_destructive_tool` are visible in `tools/list`). Tool invocation is in-process — no subprocess fork. The adapter injects `--agent` automatically for all invocations. Wizard sessions are FSM-based with TTL cleanup. Resources and prompts are registered via `ResourceProvidersFactory` and `WithPrompts()`.

**Output Formatting**: The `Output` interface provides methods for formatted output (`Print`, `Printf`, `PrintTable`, `PrintJSON`, etc.) with both human-readable and JSON implementations, selected by the `--json` flag.

Expand Down
2 changes: 1 addition & 1 deletion cmd/mcp-test-server/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Command mcp-test-server boots the swagger-generated fake Pinner API
// (internal/mcptest) on a localhost port. It is the upstream API double used
// by the Sunpeak MCP end-to-end tests: the pinner MCP server is pointed at
// this endpoint so `invoke_tool` calls return real data instead of an
// this endpoint so the invoke dispatchers return real data instead of an
// "authentication required" error.
//
// It is a test-only binary, not part of the production pinner command.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ the frontends.
- `catalog.go` — `ToolCatalog`: the in-memory registry behind a **two-tier
tool surface**. Curated, most-used tools are listed directly in
`tools/list`; the remaining catalog is served through progressive disclosure
(`search_tools` → `describe_tool` → `invoke_tool`), keeping the initial tool
(`search_tools` → `describe_tool` → the typed invoke dispatchers
`invoke_read_tool`/`invoke_write_tool`/`invoke_destructive_tool`), keeping the initial tool
surface small and the context budget predictable.
- `catalogassembly.go` — `AssembleCatalogOps(deps *CatalogDepsBundle)` builds
one catalog covering every domain (auth, account, vault, pins, websites,
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-host-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ It is **not** an MCP tool.
Do not pass `<host PUT>` to:

- `describe_tool`
- `invoke_tool`
- the typed invoke dispatchers (`invoke_read_tool` / `invoke_write_tool` / `invoke_destructive_tool`)

It represents the host action of PUTing bytes to the minted URL.

Expand Down
12 changes: 7 additions & 5 deletions internal/mcp/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ var serverCardTools = []map[string]any{
{"name": "upload_file", "description": "Upload a file to IPFS"},
{"name": "search_tools", "description": "Search the tool catalog"},
{"name": "describe_tool", "description": "Get a tool's input schema"},
{"name": "invoke_tool", "description": "Invoke a catalog tool"},
{"name": "invoke_read_tool", "description": "Invoke a read-only catalog tool"},
{"name": "invoke_write_tool", "description": "Invoke a mutating catalog tool"},
{"name": "invoke_destructive_tool", "description": "Invoke a destructive catalog tool"},
}

// serverCardHandler serves the static MCP server card used by directory
Expand Down Expand Up @@ -766,7 +768,7 @@ func routeVaultSetupHandlers(catalog *ToolCatalog, create, restore model.PinnerT
// mcpInstructionsBase is sent to MCP clients in the initialize response.
const mcpInstructionsBase = `This server exposes a curated set of common Pinner tools directly, including upload, pin, list, status, download, vault, website, website/domain wizard tools, and the agent-facing out-of-band sign-in tools (auth_sso and auth_resume). Setup wizard tools are kept out of the curated direct list because they duplicate the auth_sso/vault_create/vault_restore flows for CLI-style onboarding; they never accept passwords or OTP over this channel and remain reachable via search_tools.

The tool surface is intentionally two-tier. The tools listed directly in tools/list are the curated, most-used surface. The rest of the catalog (see count below) is served through progressive disclosure and is NOT broken or missing: any tool not listed directly is reachable via search_tools -> describe_tool -> invoke_tool. If a tool you expect is absent from tools/list, search for it rather than assuming it is unavailable. A large catalog is deliberately kept off the direct list to keep the initial tool surface small and the context budget predictable.
The tool surface is intentionally two-tier. The tools listed directly in tools/list are the curated, most-used surface. The rest of the catalog (see count below) is served through progressive disclosure and is NOT broken or missing: any tool not listed directly is reachable via search_tools -> describe_tool -> invoke_read_tool/invoke_write_tool/invoke_destructive_tool (the describe_tool response names the typed dispatcher for each tool). If a tool you expect is absent from tools/list, search for it rather than assuming it is unavailable. A large catalog is deliberately kept off the direct list to keep the initial tool surface small and the context budget predictable.

For authentication, prefer the out-of-band flow: call auth_sso, give the returned approval URL to the human, then poll auth_resume with the returned handle until it reports done. This avoids an invalid or missing API key blocking work.

Expand All @@ -779,12 +781,12 @@ Common flows start here:
- search: search_tools({ "query": "<one keyword>" })
- filter: search_tools({ "category": "vault", "query": "<one keyword>" })

Some internal commands are human-only or read piped stdin; when an agent invokes one via invoke_tool, the server returns a structured needs_human redirect instead of blocking. Commands that prompt interactively are hidden from search_tools entirely.
Some internal commands are human-only or read piped stdin; when an agent invokes one via the invoke dispatchers, the server returns a structured needs_human redirect instead of blocking. Commands that prompt interactively are hidden from search_tools entirely.

Less common CLI tools remain available through progressive disclosure:
1. search_tools({ "query": "..." }): Find tools by keyword. Returns matching names, descriptions, and categories.
2. describe_tool({ "name": "..." }): Get the full input schema for one internal tool.
3. invoke_tool({ "name": "...", "arguments": { ... } }): Execute one internal tool.
2. describe_tool({ "name": "..." }): Get the full input schema for one internal tool; the response carries invokeTool, the dispatcher that executes it.
3. invoke_read_tool / invoke_write_tool / invoke_destructive_tool({ "name": "...", "arguments": { ... } }): Execute one internal tool with the dispatcher named by invokeTool.

The internal catalog has %d tools. Local path arguments refer to the MCP server host, not the remote agent's filesystem. Upload and vault copy therefore require a host-side file handoff. File attachments can use the directly visible upload_file (IPFS) and vault_put_file (vault) tools over the banner-visible source modes; Pinner fetches the temporary file URL locally and uses its existing authenticated TUS path. Large uploads use TUS internally; the SDK result includes an upload location for resume/status management. TUS is never anonymous. Vault cat returns bounded base64 JSON in agent mode and never writes raw bytes to the MCP transport.`

Expand Down
39 changes: 30 additions & 9 deletions internal/mcp/annotations_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ package mcp
// booleans (readOnlyHint, destructiveHint, openWorldHint); a nil pointer
// hint is emitted as null on the wire and fails validators.
// - The progressive-disclosure meta-tools (search_tools/describe_tool/
// invoke_tool) additionally carry top-level titles (the Claude directory
// submission requires them; annotations.title is only a legacy fallback).
// invoke_read_tool/invoke_write_tool/invoke_destructive_tool) additionally
// carry top-level titles (the Claude directory submission requires them;
// annotations.title is only a legacy fallback).
// - The typed invoke dispatchers carry truthfully split safety hints
// (read: readOnly=true; mutating: openWorld=true; destructive:
// destructive=true, openWorld=true) so no single MCP tool straddles the
// safe/unsafe boundary the directory validators reject.
// - auth_status's hints declare the platform-required values (an out-of-band
// sign-in email cannot be unsent, so readOnly=false / destructive=true).
// - Every ui:// app view declares one exact HTTPS origin (_meta.ui.domain
Expand Down Expand Up @@ -63,20 +68,36 @@ func TestWireAnnotationsOnMetaTools(t *testing.T) {
require.NoError(t, err)

titles := map[string]string{
"search_tools": "Search tool catalog",
"describe_tool": "Describe a catalog tool",
"invoke_tool": "Invoke a catalog tool",
"search_tools": "Search tool catalog",
"describe_tool": "Describe a catalog tool",
"invoke_read_tool": "Invoke a read-only catalog tool",
"invoke_write_tool": "Invoke a mutating catalog tool",
"invoke_destructive_tool": "Invoke a destructive catalog tool",
}
wantHints := map[string][3]bool{
// {readOnlyHint, destructiveHint, openWorldHint}
"search_tools": {false, false, false},
"describe_tool": {false, false, false},
"invoke_read_tool": {true, false, false},
"invoke_write_tool": {false, false, true},
"invoke_destructive_tool": {false, true, true},
}
seen := map[string]bool{}
for _, tool := range res.Tools {
requireBoolHints(t, tool)
seen[tool.Name] = true
want, ok := titles[tool.Name]
if !ok {
continue
if ok {
require.Equal(t, want, tool.Title, "%s: top-level title required for directory submission", tool.Name)
require.Equal(t, want, tool.Annotations.Title, "%s: annotations.title mirrors the title", tool.Name)
}
if hints, ok := wantHints[tool.Name]; ok {
require.Equal(t, hints[0], tool.Annotations.ReadOnlyHint, "%s: readOnlyHint", tool.Name)
require.NotNil(t, tool.Annotations.DestructiveHint, "%s: destructiveHint present", tool.Name)
require.Equal(t, hints[1], *tool.Annotations.DestructiveHint, "%s: destructiveHint", tool.Name)
require.NotNil(t, tool.Annotations.OpenWorldHint, "%s: openWorldHint present", tool.Name)
require.Equal(t, hints[2], *tool.Annotations.OpenWorldHint, "%s: openWorldHint", tool.Name)
}
require.Equal(t, want, tool.Title, "%s: top-level title required for directory submission", tool.Name)
require.Equal(t, want, tool.Annotations.Title, "%s: annotations.title mirrors the title", tool.Name)
}
for name := range titles {
require.True(t, seen[name], "%s must be listed", name)
Expand Down
10 changes: 5 additions & 5 deletions internal/mcp/apps_wire_visible_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,11 @@ func TestOfficialToolHandlerAnnotatesHandoffEndToEnd(t *testing.T) {
require.Contains(t, uiText, "https://example.com/account/password/tok", "URL must be preserved")
}

// TestInvokeToolAnnotatesAppBackedHandoff regresses the invoke_tool meta-path:
// TestInvokeToolAnnotatesAppBackedHandoff regresses the typed-invoke meta-path:
// a non-DirectVisible, app-backed catalog tool (e.g. vault_create/vault_restore)
// is dispatched by the invoke_tool closure directly to the inner catalog
// is dispatched by the invoke_write_tool closure directly to the inner catalog
// handler, so the outer officialToolHandler annotation (keyed on the wired name
// "invoke_tool") never sees the real tool. The closure must annotate with the
// "invoke_write_tool") never sees the real tool. The closure must annotate with the
// resolved inner name so a text-only host still learns the companion app exists.
func TestInvokeToolAnnotatesAppBackedHandoff(t *testing.T) {
registerTestAppView(t, "vault_create", apps.AppViewInfo{
Expand Down Expand Up @@ -183,7 +183,7 @@ func TestInvokeToolAnnotatesAppBackedHandoff(t *testing.T) {
cs := connectOfficialClient(t, srv)

res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
Name: "invoke_tool",
Name: "invoke_write_tool",
Arguments: map[string]any{
"name": "vault_create",
"arguments": map[string]any{},
Expand All @@ -193,7 +193,7 @@ func TestInvokeToolAnnotatesAppBackedHandoff(t *testing.T) {
require.False(t, res.IsError)
require.NotNil(t, res.Content, "expected text content")
text := requireText(t, res)
require.Contains(t, text, "Create Vault", "companion-app title must annotate invoke_tool-dispatched hand-off")
require.Contains(t, text, "Create Vault", "companion-app title must annotate invoke_write_tool-dispatched hand-off")
require.Contains(t, text, "is also available in Apps-capable clients", "text-only client expects fallback wording")
require.Contains(t, text, "https://example.com/vault/create/tok", "raw URL must be preserved")
}
22 changes: 15 additions & 7 deletions internal/mcp/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,20 @@ type ToolDetail struct {
ReadOnly bool `json:"readOnlyHint"`
Destructive bool `json:"destructiveHint"`
Interaction model.Interaction `json:"interaction,omitempty"`
InputSchema json.RawMessage `json:"inputSchema"`
// InvokeTool names the typed invoke dispatcher that executes this tool
// (invoke_read_tool / invoke_write_tool / invoke_destructive_tool), so an
// agent never has to guess which safety class a tool belongs to when the
// typed dispatchers enforce the split.
InvokeTool string `json:"invokeTool,omitempty"`
InputSchema json.RawMessage `json:"inputSchema"`
}

// ToolCatalog is an in-memory registry of tools that are discovered through
// the meta-tools (search_tools, describe_tool, invoke_tool) instead of being
// listed directly in tools/list. This implements server-side progressive
// disclosure: the MCP client sees only 3 meta-tools, while the real tool
// catalog stays internal.
// the meta-tools (search_tools, describe_tool, and the typed invoke
// dispatchers invoke_read_tool / invoke_write_tool / invoke_destructive_tool)
// instead of being listed directly in tools/list. This implements server-side
// progressive disclosure: the MCP client sees only the meta-tools, while the
// real tool catalog stays internal.
type ToolCatalog struct {
mu sync.RWMutex
tools map[string]*model.ToolEntry
Expand Down Expand Up @@ -304,7 +310,7 @@ func isPrimaryTool(name string) bool {

// Suggest returns up to max tool names close to the given (unknown) name,
// ordered by ascending Levenshtein distance then name. It lets describe_tool
// and invoke_tool answer with "did you mean ...?" instead of a bare
// and the invoke dispatchers answer with "did you mean ...?" instead of a bare
// unknown-tool error. Tools that Search deliberately hides — wizards and
// interactive/human-only tools — are excluded so suggestions never surface a
// tool the agent could not discover. Distance uses a local zero-dependency
Expand Down Expand Up @@ -408,6 +414,7 @@ func (c *ToolCatalog) Describe(name string) (*ToolDetail, error) {
ReadOnly: entry.ReadOnly,
Destructive: entry.Destructive,
Interaction: entry.Interaction,
InvokeTool: classifyEntry(entry).dispatcher(),
InputSchema: entry.InputSchema,
}, nil
}
Expand Down Expand Up @@ -435,6 +442,7 @@ func (c *ToolCatalog) DescribeFor(name string, profile *hostenv.PlatformProfile)
ReadOnly: entry.ReadOnly,
Destructive: entry.Destructive,
Interaction: entry.Interaction,
InvokeTool: classifyEntry(entry).dispatcher(),
InputSchema: entry.InputSchema,
}, nil
}
Expand Down Expand Up @@ -525,7 +533,7 @@ func (c *ToolCatalog) Invoke(ctx context.Context, name string, args map[string]a
return model.ToolResult{}, fmt.Errorf("unknown tool: %s", name)
}
if entry.Category == model.CategoryAdmin {
return model.ToolResult{IsError: true, Text: fmt.Sprintf("admin tool %s is not available through invoke_tool; use search_tools with category=admin to discover admin tools", name)}, nil
return model.ToolResult{IsError: true, Text: fmt.Sprintf("admin tool %s is not available through the invoke dispatchers; use search_tools with category=admin to discover admin tools", name)}, nil
}

log.Info("meta-tool invoke", zap.String("tool", name))
Expand Down
Loading
Loading