From 8bee9e5797daf7223668e134743d61c1f163b5f1 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sat, 12 Sep 2026 20:44:02 +0000 Subject: [PATCH] refactor(mcp): use shared appswire toolInvocation meta helper Switches CLI-local app-helper _meta to appswire.ToolInvocationMeta, replacing manual nested openai/toolInvocation objects with the contract's flat slash-delimited keys. Adds ResourceURI and Visibility to the handoff ResumeToolSpec so the *_status and *_resume tools register their view attachment through the shared helper, and bumps go.lumeweb.com/pinner to the merge commit exposing meta.go. --- go.mod | 2 +- go.sum | 6 +-- internal/mcp/apps/apps.go | 22 +++++++--- internal/mcp/apps_test.go | 41 +++++++++++++++++++ internal/mcp/auth/auth_sso.go | 6 ++- internal/mcp/auth/auth_sso_app.go | 6 ++- internal/mcp/core/handoff/handoff_registry.go | 39 +++++++++++++----- .../mcp/core/handoff/handoff_registry_test.go | 39 ++++++++++++++++-- internal/mcp/mcptargets_test.go | 5 ++- internal/mcp/oob/vault_handoff.go | 12 ++++-- internal/mcp/upload/upload_vault_app.go | 22 +++++++--- internal/mcp/upload_vault_app_test.go | 22 ++++++++++ internal/mcp/vault/vault_create_app.go | 6 ++- internal/mcp/vault/vault_restore_app.go | 6 ++- 14 files changed, 190 insertions(+), 44 deletions(-) diff --git a/go.mod b/go.mod index 74573488..c7bfe14a 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( go.lumeweb.com/mcpplane v0.0.0-20260912095121-3753dd085aa8 go.lumeweb.com/oauth v0.1.6 go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 - go.lumeweb.com/pinner v0.0.0-20260912182741-cd1c1d950ad1 + go.lumeweb.com/pinner v0.0.0-20260912202155-e995b1990984 go.lumeweb.com/portal-sdk v0.1.72 go.lumeweb.com/queryutil v0.3.19 go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae diff --git a/go.sum b/go.sum index 19dfec36..2ba8062a 100644 --- a/go.sum +++ b/go.sum @@ -753,10 +753,8 @@ go.lumeweb.com/oauth v0.1.6 h1:6c6LrXxMwx5klbq3OshzXjkGwvYVjAQhjX2FxCAgqqg= go.lumeweb.com/oauth v0.1.6/go.mod h1:Bfdxi1gkv+Ypj9yj9uOSmKnbZX8C7q7Pyn1OdPyuCbM= go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 h1:kb2adBZ8Ed11yuXoQw2I8LfmbPjpPkc0NPwyShKJ5KE= go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9/go.mod h1:DbO27Lr6pBSzC+HogQMNNVEZMvkAHYwckL7i79aTETM= -go.lumeweb.com/pinner v0.0.0-20260912180402-b6294ce18ac9 h1:vs9Qyq/fxv2Bm11ZFBZXr0GMpTZWQWIyimAkTAc00SM= -go.lumeweb.com/pinner v0.0.0-20260912180402-b6294ce18ac9/go.mod h1:ni3gEeGNpTZ5xOwtO9p87G0yChjaEeSTqVId4n2b5pY= -go.lumeweb.com/pinner v0.0.0-20260912182741-cd1c1d950ad1 h1:HTSN3qZRHKd25UIni+crbBUTJrP9jwqYD6aHo+M91q4= -go.lumeweb.com/pinner v0.0.0-20260912182741-cd1c1d950ad1/go.mod h1:ni3gEeGNpTZ5xOwtO9p87G0yChjaEeSTqVId4n2b5pY= +go.lumeweb.com/pinner v0.0.0-20260912202155-e995b1990984 h1:60DfGrCme2F4Y0PGqilEdwgWpAgM3c8ZDFVd9ryKnnc= +go.lumeweb.com/pinner v0.0.0-20260912202155-e995b1990984/go.mod h1:ni3gEeGNpTZ5xOwtO9p87G0yChjaEeSTqVId4n2b5pY= go.lumeweb.com/portal v0.5.1 h1:l28uCNFmT+VewwRGhFwlmonYEwLxQfRh06hm7n0SQr8= go.lumeweb.com/portal v0.5.1/go.mod h1:JXy/eHHlUdXMsPbXSbRyX2ZM7s/OGNfSNsXH7XRYWCk= go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R737H7c= diff --git a/internal/mcp/apps/apps.go b/internal/mcp/apps/apps.go index b862365a..785fda17 100644 --- a/internal/mcp/apps/apps.go +++ b/internal/mcp/apps/apps.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.lumeweb.com/pinner/mcp/appswire" "go.lumeweb.com/pinner-cli/internal/mcpapp" "go.lumeweb.com/mcpplane/model" @@ -63,12 +65,9 @@ func pinStatusDescriptor(pins PinningProvider) model.ToolDescriptor { // OpenAI tool invocation labels shown by UI-capable hosts while the // tool runs and after it finishes. Required alongside the openai // outputTemplate this app helper carries. - Meta: map[string]any{ - "openai/toolInvocation": map[string]any{ - "invoking": "Checking pin status…", - "invoked": "Pin status checked", - }, - }, + Meta: mustToolInvocationMeta(appswire.ToolInvocationMeta(PinCreateAppURI, + []model.ToolVisibility{model.ToolVisibilityApp}, + "Checking pin status…", "Pin status checked")), Handler: func(ctx context.Context, req model.ToolRequest) (model.ToolResult, error) { cid, _ := req.Arguments["cid"].(string) if cid == "" { @@ -86,6 +85,17 @@ func pinStatusDescriptor(pins PinningProvider) model.ToolDescriptor { } } +// mustToolInvocationMeta unwraps appswire.ToolInvocationMeta's result for app +// helpers whose URI and labels are constant and non-empty — the only way the +// meta build can fail. A failure is a compile-time-wiring divergence (an empty +// constant), so it fails loudly instead of being swallowed. +func mustToolInvocationMeta(meta mcp.Meta, err error) mcp.Meta { + if err != nil { + panic(err) + } + return meta +} + // RegisterPinApp wires the complete "Create a Pin" MCP App: attaches the // ui:// view to the direct pins_add tool, registers the ui://pins/create.html // HTML resource, and registers the app-only pin_status polling helper. It is diff --git a/internal/mcp/apps_test.go b/internal/mcp/apps_test.go index ccbe18d3..59f54b54 100644 --- a/internal/mcp/apps_test.go +++ b/internal/mcp/apps_test.go @@ -18,6 +18,18 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/vault" ) +// assertFlatToolInvocation asserts the toolInvocation labels sit at their flat +// slash-delimited _meta keys with the given values. +func assertFlatToolInvocation(t *testing.T, meta map[string]any, invoking, invoked string) { + t.Helper() + if got, ok := meta["openai/toolInvocation/invoking"].(string); !ok || got != invoking { + t.Fatalf("openai/toolInvocation/invoking = %#v, want %q", meta["openai/toolInvocation/invoking"], invoking) + } + if got, ok := meta["openai/toolInvocation/invoked"].(string); !ok || got != invoked { + t.Fatalf("openai/toolInvocation/invoked = %#v, want %q", meta["openai/toolInvocation/invoked"], invoked) + } +} + // fakePins is a controllable PinningProvider for app tests. type fakePins struct { status string @@ -158,6 +170,35 @@ func TestPinStatusHelperInvoke(t *testing.T) { } } +// TestPinStatusHelperToolInvocationMetaFlat pins the toolInvocation labels at +// the flat slash-delimited _meta keys the reference contract reads +// (openai/toolInvocation/invoking + /invoked), matching the shared +// appswire.ToolInvocationMeta helper — never a nested object. +func TestPinStatusHelperToolInvocationMetaFlat(t *testing.T) { + pins := &fakePins{status: "pinning"} + srv := buildPinAppServer(t, pins) + cs := connectOfficialClient(t, srv) + tres, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + var meta map[string]any + for _, x := range tres.Tools { + if x.Name == "pin_status" { + meta = x.Meta + break + } + } + if meta == nil { + t.Fatalf("pin_status not listed") + } + assertFlatToolInvocation(t, meta, "Checking pin status…", "Pin status checked") + // The deprecated nested-object shape must be gone. + if _, nested := meta["openai/toolInvocation"]; nested { + t.Fatalf("pin_status must not carry the nested openai/toolInvocation object: %#v", meta["openai/toolInvocation"]) + } +} + func TestPinCreateResourceRead(t *testing.T) { srv := buildPinAppServer(t, &fakePins{status: "pinned"}) cs := connectOfficialClient(t, srv) diff --git a/internal/mcp/auth/auth_sso.go b/internal/mcp/auth/auth_sso.go index 51de5236..800248b4 100644 --- a/internal/mcp/auth/auth_sso.go +++ b/internal/mcp/auth/auth_sso.go @@ -227,7 +227,7 @@ func NewAuthSSORevokeDescriptor(oob *OutOfBandLogin, handles *session.AsyncHandl // dead-handle guidance are SSO-specific; the dispatch logic (handle validation, // expiry, continuation lookup) is shared via handoff.NewResumeTool. func NewAuthResumeDescriptor(reg *handoff.HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { - return handoff.NewResumeTool(handoff.ResumeToolSpec{ + return handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ Name: "auth_resume", Title: "Auth Sign-In Resume", Description: "Poll a pending out-of-band (OOB) sign in to check whether the human has completed the SSO approval (sign-in). Returns pending (needs_human) until approval is done, then reports done. Pass the handle returned by auth_sso.", @@ -236,5 +236,7 @@ func NewAuthResumeDescriptor(reg *handoff.HandoffRegistry, handles *session.Asyn ExpiredHandleDetail: "the sign-in handle expired before the human completed approval; start a fresh login with auth_sso and have the user approve promptly", DeadHandleReason: model.ReasonSSOApproval, Category: model.CategoryAccount, - }, reg, handles) + ResourceURI: AuthSSOAppURI, + Visibility: []model.ToolVisibility{model.ToolVisibilityModel, model.ToolVisibilityApp}, + }, reg, handles)) } diff --git a/internal/mcp/auth/auth_sso_app.go b/internal/mcp/auth/auth_sso_app.go index afff38c1..0ba9c6e0 100644 --- a/internal/mcp/auth/auth_sso_app.go +++ b/internal/mcp/auth/auth_sso_app.go @@ -40,7 +40,7 @@ func RenderAuthSSOAppHTML() string { // auth_resume, but is registered with model.ToolVisibilityApp so only the Sign In // view can poll it; the model never sees it. It carries no secrets. func authSSOStatusDescriptor(reg *handoff.HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { - return handoff.NewResumeTool(handoff.ResumeToolSpec{ + return handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ Name: "auth_sso_status", Title: "Auth Sign-In Status", Description: "Poll a pending out-of-band sign-in by handle. App-only helper for the Sign In view.", @@ -49,7 +49,9 @@ func authSSOStatusDescriptor(reg *handoff.HandoffRegistry, handles *session.Asyn ExpiredHandleDetail: "the sign-in handle expired before approval; start a fresh login with auth_sso", DeadHandleReason: model.ReasonSSOApproval, Category: model.CategoryAccount, - }, reg, handles) + ResourceURI: AuthSSOAppURI, + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, reg, handles)) } // RegisterAuthSSOApp wires the complete "Sign In" MCP App onto the shared diff --git a/internal/mcp/core/handoff/handoff_registry.go b/internal/mcp/core/handoff/handoff_registry.go index 0efcc2cc..d14d3439 100644 --- a/internal/mcp/core/handoff/handoff_registry.go +++ b/internal/mcp/core/handoff/handoff_registry.go @@ -11,6 +11,7 @@ import ( "go.lumeweb.com/mcpplane/model" "go.lumeweb.com/mcpplane/toolargs" + "go.lumeweb.com/pinner/mcp/appswire" "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" ) @@ -226,6 +227,15 @@ type ResumeToolSpec struct { // mcpplane/model.ToolCategory's documented "vault" -> CategoryStorage // entry. Clients that filter a tools/list by category must use "storage". Category model.ToolCategory + // ResourceURI is the ui:// view URI the resume tool's view attachment + // points at. It feeds appswire.ToolInvocationMeta's ui.resourceUri and must + // be non-empty (a wiring bug when missing). + ResourceURI string + // Visibility is the wrapped tool's visibility (app-only *_status helpers + // use []model.ToolVisibility{model.ToolVisibilityApp}; model-facing + // *_resume tools use [Model, App]). It feeds appswire.ToolInvocationMeta's + // ui.visibility. + Visibility []model.ToolVisibility } // CategoryOrDefault returns the resume tool's category, defaulting to @@ -267,7 +277,12 @@ func (s ResumeToolSpec) deadHandleReason() model.HandoffReason { // A domain supplies its tool spec (name, description, restart steering, and // dead-handle guidance text). Example: SSO calls // NewResumeTool(ResumeToolSpec{Name: "auth_resume", ...}, reg, handles). -func NewResumeTool(spec ResumeToolSpec, reg *HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { +func NewResumeTool(spec ResumeToolSpec, reg *HandoffRegistry, handles *session.AsyncHandleStore) (model.ToolDescriptor, error) { + meta, err := appswire.ToolInvocationMeta(spec.ResourceURI, spec.Visibility, + "Checking hand-off status…", "Hand-off status checked") + if err != nil { + return model.ToolDescriptor{}, err + } return model.ToolDescriptor{ Name: spec.Name, Title: spec.title(), @@ -275,15 +290,7 @@ func NewResumeTool(spec ResumeToolSpec, reg *HandoffRegistry, handles *session.A Category: spec.CategoryOrDefault(), MCPTargets: toolforge.MCPTargets(toolforge.Fallback(spec.Description)), InputSchema: toolargs.ToolSchemaFor[resumeArgs](), - // OpenAI tool invocation labels shown by UI-capable hosts while the - // tool runs and after it finishes. Required alongside the openai - // outputTemplate the app-helper status variants carry. - Meta: map[string]any{ - "openai/toolInvocation": map[string]any{ - "invoking": "Checking hand-off status…", - "invoked": "Hand-off status checked", - }, - }, + Meta: meta, Handler: func(ctx context.Context, req model.ToolRequest) (model.ToolResult, error) { if reg == nil || handles == nil { return model.NeedsHumanResult(model.NeedsHuman{ @@ -338,7 +345,19 @@ func NewResumeTool(spec ResumeToolSpec, reg *HandoffRegistry, handles *session.A } return result, nil }, + }, nil +} + +// MustResumeTool unwraps NewResumeTool's (descriptor, error) result for callers +// whose spec carries constant, non-empty ResourceURI/labels — the only way the +// meta build can fail. A failure is therefore a compile-time-wiring divergence +// (an empty constant spec), so it fails loudly instead of being swallowed; the +// domain descriptor wrappers keep their single-value ergonomics. +func MustResumeTool(desc model.ToolDescriptor, err error) model.ToolDescriptor { + if err != nil { + panic(err) } + return desc } // isTerminalResume reports whether a resume result is terminal (done) rather diff --git a/internal/mcp/core/handoff/handoff_registry_test.go b/internal/mcp/core/handoff/handoff_registry_test.go index 55e28ebd..50d07a47 100644 --- a/internal/mcp/core/handoff/handoff_registry_test.go +++ b/internal/mcp/core/handoff/handoff_registry_test.go @@ -227,14 +227,16 @@ func TestResumeTemplateDispatchesContinuation(t *testing.T) { handles := session.NewAsyncHandleStore(session.DefaultSessionTTL, session.DefaultMaxSessions) // A generic handoff flow: start tool style, then the shared resume template. - resume := NewResumeTool(ResumeToolSpec{ + resume := MustResumeTool(NewResumeTool(ResumeToolSpec{ Name: "test_flow_resume", Description: "Resume the test flow", RestartTool: "test_flow_start", UnknownHandleDetail: "unknown; start afresh", ExpiredHandleDetail: "expired; start afresh", DeadHandleReason: model.ReasonConfirmation, - }, reg, handles) + ResourceURI: "ui://test/flow.html", + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, reg, handles)) // Pending continuation (needs_human) first, then terminal done. kind := "pending" @@ -277,6 +279,33 @@ func TestResumeTemplateDispatchesContinuation(t *testing.T) { assert.False(t, after, "terminal resume must drop the continuation") } +// TestResumeToolInvocationMetaFlat pins the resume template's toolInvocation +// labels at the flat slash-delimited _meta keys (never a nested object), the +// contract established by appswire.ToolInvocationMeta. +func TestResumeToolInvocationMetaFlat(t *testing.T) { + reg := NewHandoffRegistry() + handles := session.NewAsyncHandleStore(session.DefaultSessionTTL, session.DefaultMaxSessions) + desc := MustResumeTool(NewResumeTool(ResumeToolSpec{ + Name: "test_flow_resume", + ResourceURI: "ui://test/flow.html", + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, reg, handles)) + require.Equal(t, "Checking hand-off status…", desc.Meta["openai/toolInvocation/invoking"]) + require.Equal(t, "Hand-off status checked", desc.Meta["openai/toolInvocation/invoked"]) + _, nested := desc.Meta["openai/toolInvocation"] + require.False(t, nested, "resume meta must not carry the nested openai/toolInvocation object") +} + +// TestResumeToolInvocationMetaRequiresURI verifies the resume template refuses +// a spec that omits its ui:// resource URI (a wiring bug), matching the +// appswire.ToolInvocationMeta error contract instead of silently dropping meta. +func TestResumeToolInvocationMetaRequiresURI(t *testing.T) { + reg := NewHandoffRegistry() + handles := session.NewAsyncHandleStore(session.DefaultSessionTTL, session.DefaultMaxSessions) + _, err := NewResumeTool(ResumeToolSpec{Name: "test_flow_resume"}, reg, handles) + require.Error(t, err) +} + // TestResumeTemplateDeadHandleSteersRestart verifies that a handle with no // registered continuation (never started or already completed) steers the agent // to the restart tool rather than leaving it polling. @@ -284,13 +313,15 @@ func TestResumeTemplateDeadHandleSteersRestart(t *testing.T) { reg := NewHandoffRegistry() handles := session.NewAsyncHandleStore(session.DefaultSessionTTL, session.DefaultMaxSessions) - resume := NewResumeTool(ResumeToolSpec{ + resume := MustResumeTool(NewResumeTool(ResumeToolSpec{ Name: "test_flow_resume", RestartTool: "test_flow_start", UnknownHandleDetail: "unknown handle; start a new flow", ExpiredHandleDetail: "expired; start a fresh flow", DeadHandleReason: model.ReasonConfirmation, - }, reg, handles) + ResourceURI: "ui://test/flow.html", + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, reg, handles)) // A handle that exists in the store but has NO continuation registered. orphan := handles.Create("pending", map[string]any{"flow": "x"}) diff --git a/internal/mcp/mcptargets_test.go b/internal/mcp/mcptargets_test.go index b6480cbe..6473fbc1 100644 --- a/internal/mcp/mcptargets_test.go +++ b/internal/mcp/mcptargets_test.go @@ -59,7 +59,10 @@ func TestToolRegistrationsCarryMCPTargets(t *testing.T) { // so exercising it once covers auth_resume / vault_create_resume / // vault_restore_resume. requireMCPTargets(t, auth.NewAuthSSODescriptor(nil, nil, nil)) - requireMCPTargets(t, handoff.NewResumeTool(handoff.ResumeToolSpec{Name: "resume_tool", Description: "x"}, nil, nil)) + requireMCPTargets(t, handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ + Name: "resume_tool", Description: "x", ResourceURI: "ui://test/flow.html", + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, nil, nil))) requireMCPTargets(t, auth.NewAccountPasswordUpdateDescriptor(nil, nil, nil, nil)) requireMCPTargets(t, auth.NewAccountPasswordResetDescriptor(nil, "")) requireMCPTargets(t, auth.NewAccountEmailChangeDescriptor(nil, nil)) diff --git a/internal/mcp/oob/vault_handoff.go b/internal/mcp/oob/vault_handoff.go index c761488e..7dc5b247 100644 --- a/internal/mcp/oob/vault_handoff.go +++ b/internal/mcp/oob/vault_handoff.go @@ -305,7 +305,7 @@ func vaultExpiredResult(handles *session.AsyncHandleStore, reg *handoff.HandoffR // built from the shared resume template. Name/description and restart steering // are create-flavored: a dead handle steers back to vault_create. func NewVaultCreateResumeDescriptor(reg *handoff.HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { - return handoff.NewResumeTool(handoff.ResumeToolSpec{ + return handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ Name: VaultCreateResumeToolName, Title: "Vault Create Resume", Description: "Poll a pending vault create hand-off to check whether the human has approved the Sia device connection on the one-time create_url and retrieved the recovery seed. Returns pending (needs_human) until the vault is active and the seed has been retrieved, then reports done. Pass the handle returned by vault_create.", @@ -314,14 +314,16 @@ func NewVaultCreateResumeDescriptor(reg *handoff.HandoffRegistry, handles *sessi ExpiredHandleDetail: "the vault create hand-off expired before the vault was created and the seed retrieved; start a fresh vault create with vault_create so a new create_url is minted", DeadHandleReason: model.ReasonCredentialEntry, Category: model.CategoryStorage, - }, reg, handles) + ResourceURI: vault.VaultCreateAppURI, + Visibility: []model.ToolVisibility{model.ToolVisibilityModel, model.ToolVisibilityApp}, + }, reg, handles)) } // NewVaultRestoreResumeDescriptor returns the vault_restore_resume tool, // built from the shared resume template. Name/description and restart steering // are restore-flavored: a dead handle steers back to vault_restore. func NewVaultRestoreResumeDescriptor(reg *handoff.HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { - return handoff.NewResumeTool(handoff.ResumeToolSpec{ + return handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ Name: VaultRestoreResumeToolName, Title: "Vault Restore Resume", Description: "Poll a pending vault restore hand-off to check whether the human has completed the out-of-band restore on the one-time restore_url. Returns pending (needs_human) until the restore is done, then reports done. Pass the handle returned by vault_restore.", @@ -330,5 +332,7 @@ func NewVaultRestoreResumeDescriptor(reg *handoff.HandoffRegistry, handles *sess ExpiredHandleDetail: "the vault restore hand-off expired before the human completed it; start a fresh vault restore with vault_restore so a new restore_url is minted", DeadHandleReason: model.ReasonCredentialEntry, Category: model.CategoryStorage, - }, reg, handles) + ResourceURI: vault.VaultRestoreAppURI, + Visibility: []model.ToolVisibility{model.ToolVisibilityModel, model.ToolVisibilityApp}, + }, reg, handles)) } diff --git a/internal/mcp/upload/upload_vault_app.go b/internal/mcp/upload/upload_vault_app.go index 409dbb98..c3492f2d 100644 --- a/internal/mcp/upload/upload_vault_app.go +++ b/internal/mcp/upload/upload_vault_app.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.lumeweb.com/pinner/mcp/appswire" "go.lumeweb.com/pinner-cli/internal/mcpapp" "go.lumeweb.com/mcpplane/model" @@ -78,12 +80,9 @@ func vaultUploadSubmitDescriptor(vu *transfer.VaultHTTPUpload) model.ToolDescrip // OpenAI tool invocation labels shown by UI-capable hosts while the // tool runs and after it finishes. Required alongside the openai // outputTemplate this app helper carries. - Meta: map[string]any{ - "openai/toolInvocation": map[string]any{ - "invoking": "Preparing vault upload endpoint…", - "invoked": "Vault upload endpoint ready", - }, - }, + Meta: mustToolMeta(appswire.ToolInvocationMeta(VaultUploadAppURI, + []model.ToolVisibility{model.ToolVisibilityApp}, + "Preparing vault upload endpoint…", "Vault upload endpoint ready")), Handler: func(ctx context.Context, req model.ToolRequest) (model.ToolResult, error) { in, err := toolargs.DecodeToolArgs[VaultUploadSubmitInput](req) if err != nil { @@ -125,6 +124,17 @@ func vaultUploadSubmitDescriptor(vu *transfer.VaultHTTPUpload) model.ToolDescrip } } +// mustToolMeta unwraps appswire.ToolInvocationMeta's result for app helpers +// whose URI and labels are constant and non-empty — the only way the meta build +// can fail. A failure is a compile-time-wiring divergence (an empty constant), +// so it fails loudly instead of being swallowed. +func mustToolMeta(meta mcp.Meta, err error) mcp.Meta { + if err != nil { + panic(err) + } + return meta +} + // RegisterVaultUploadApp wires the complete "Upload to Vault" MCP App: attaches // the ui:// view to the vault_put_file tool, registers the ui://uploads/vault.html // HTML resource, and registers the app-only vault_upload_submit mint helper. The diff --git a/internal/mcp/upload_vault_app_test.go b/internal/mcp/upload_vault_app_test.go index 6c53a2a4..553049c0 100644 --- a/internal/mcp/upload_vault_app_test.go +++ b/internal/mcp/upload_vault_app_test.go @@ -445,3 +445,25 @@ func TestVaultUploadResourceAdvertisesConnectDomains(t *testing.T) { cd2 := ui2["csp"].(map[string]any)["connectDomains"].([]any) require.Equal(t, "https://tunnel.example.com", cd2[0], "tunnel connectDomains[0]") } + +// TestVaultUploadSubmitHelperToolInvocationMetaFlat pins the vault_upload_submit +// helper's toolInvocation labels at the flat slash-delimited _meta keys, matching +// the shared appswire.ToolInvocationMeta helper. +func TestVaultUploadSubmitHelperToolInvocationMetaFlat(t *testing.T) { + srv := buildVaultUploadAppServer(t, nil) + cs := connectOfficialClient(t, srv) + tres, err := cs.ListTools(context.Background(), nil) + require.NoError(t, err) + var meta map[string]any + for _, x := range tres.Tools { + if x.Name == "vault_upload_submit" { + meta = x.Meta + break + } + } + require.NotNil(t, meta, "vault_upload_submit not listed") + assertFlatToolInvocation(t, meta, "Preparing vault upload endpoint…", "Vault upload endpoint ready") + if _, nested := meta["openai/toolInvocation"]; nested { + t.Fatalf("vault_upload_submit must not carry the nested openai/toolInvocation object: %#v", meta["openai/toolInvocation"]) + } +} diff --git a/internal/mcp/vault/vault_create_app.go b/internal/mcp/vault/vault_create_app.go index 485e9704..1e3f1c02 100644 --- a/internal/mcp/vault/vault_create_app.go +++ b/internal/mcp/vault/vault_create_app.go @@ -42,7 +42,7 @@ func RenderVaultCreateAppHTML() string { // only the Create Vault view can poll it; the model never sees it. It carries // no secrets (the seed never crosses this channel). func VaultCreateStatusDescriptor(reg *handoff.HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { - return handoff.NewResumeTool(handoff.ResumeToolSpec{ + return handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ Name: "vault_create_status", Title: "Vault Create Status", Description: "Poll a pending vault create hand-off by handle. App-only helper for the Create Vault view.", @@ -51,7 +51,9 @@ func VaultCreateStatusDescriptor(reg *handoff.HandoffRegistry, handles *session. ExpiredHandleDetail: "the vault create hand-off expired before the vault was created and the seed retrieved; start a fresh vault create with vault_create", DeadHandleReason: model.ReasonCredentialEntry, Category: model.CategoryStorage, - }, reg, handles) + ResourceURI: VaultCreateAppURI, + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, reg, handles)) } // RegisterVaultCreateApp wires the complete "Create Vault" MCP App onto the diff --git a/internal/mcp/vault/vault_restore_app.go b/internal/mcp/vault/vault_restore_app.go index d7aad089..19652de7 100644 --- a/internal/mcp/vault/vault_restore_app.go +++ b/internal/mcp/vault/vault_restore_app.go @@ -42,7 +42,7 @@ func RenderVaultRestoreAppHTML() string { // only the Restore Vault view can poll it; the model never sees it. It carries // no secrets (the seed is entered on the human-only page, never here). func VaultRestoreStatusDescriptor(reg *handoff.HandoffRegistry, handles *session.AsyncHandleStore) model.ToolDescriptor { - return handoff.NewResumeTool(handoff.ResumeToolSpec{ + return handoff.MustResumeTool(handoff.NewResumeTool(handoff.ResumeToolSpec{ Name: "vault_restore_status", Title: "Vault Restore Status", Description: "Poll a pending vault restore hand-off by handle. App-only helper for the Restore Vault view.", @@ -51,7 +51,9 @@ func VaultRestoreStatusDescriptor(reg *handoff.HandoffRegistry, handles *session ExpiredHandleDetail: "the vault restore hand-off expired before the human completed it; start a fresh vault restore with vault_restore", DeadHandleReason: model.ReasonCredentialEntry, Category: model.CategoryStorage, - }, reg, handles) + ResourceURI: VaultRestoreAppURI, + Visibility: []model.ToolVisibility{model.ToolVisibilityApp}, + }, reg, handles)) } // RegisterVaultRestoreApp wires the complete "Restore Vault" MCP App onto the