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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 16 additions & 6 deletions internal/mcp/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/mcp/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions internal/mcp/auth/auth_sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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))
}
6 changes: 4 additions & 2 deletions internal/mcp/auth/auth_sso_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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
Expand Down
39 changes: 29 additions & 10 deletions internal/mcp/core/handoff/handoff_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -267,23 +277,20 @@ 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(),
Description: spec.Description,
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{
Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions internal/mcp/core/handoff/handoff_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -277,20 +279,49 @@ 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.
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"})
Expand Down
5 changes: 4 additions & 1 deletion internal/mcp/mcptargets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 8 additions & 4 deletions internal/mcp/oob/vault_handoff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
Expand All @@ -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))
}
Loading
Loading