From 7a0ada31eb1bc298659c59f15dc2e400efdd986c Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sat, 12 Sep 2026 18:32:07 +0000 Subject: [PATCH] refactor(mcp): consume the shared upload-manager app seam Bumps go.lumeweb.com/pinner past the upload-manager seam and the mint poll-fragment changes, and drops the CLI's parallel copies: open_upload_manager and its app view + helper tools now come from appswire, the async upload tools from pinnermcp, and the presign-TTL parser from pinner/transfer. Launcher URI, tool names, and wording resolve from the shared shared table; upload_list stays registered because the CLI assembly runs a single-user manager in-process. --- go.mod | 2 +- go.sum | 6 +- internal/mcp/adapter_tunnel.go | 7 +- internal/mcp/annotations_wire_test.go | 11 +- internal/mcp/apps/appswire_install.go | 63 ++++ internal/mcp/core/transfer/presign_ttl.go | 38 --- .../mcp/core/transfer/presign_ttl_test.go | 68 ----- internal/mcp/core/transfer/upload_file.go | 14 +- .../mcp/custom_registration_inventory_test.go | 4 +- internal/mcp/custom_tools.go | 22 +- internal/mcp/hosted.go | 3 +- internal/mcp/hosted_concurrency_test.go | 5 +- internal/mcp/hosted_test.go | 3 +- internal/mcp/mcptargets_test.go | 8 +- .../mcp/upload/launcher_description_test.go | 19 +- internal/mcp/upload/open_upload_manager.go | 124 -------- internal/mcp/upload/open_vault_manager.go | 10 +- .../mcp/upload/schema_text_shared_test.go | 13 +- internal/mcp/upload/upload_ipfs_app.go | 279 ------------------ internal/mcp/upload/upload_tasks_test.go | 7 +- internal/mcp/upload/upload_tools.go | 86 ------ internal/mcp/upload/upload_vault_app.go | 13 +- internal/mcp/upload_file_openai_test.go | 9 +- ...app_test.go => upload_manager_app_test.go} | 35 ++- internal/mcp/vault/vault_put_file.go | 12 +- internal/mcp/wire_meta_test.go | 7 +- 26 files changed, 190 insertions(+), 678 deletions(-) create mode 100644 internal/mcp/apps/appswire_install.go delete mode 100644 internal/mcp/core/transfer/presign_ttl.go delete mode 100644 internal/mcp/core/transfer/presign_ttl_test.go delete mode 100644 internal/mcp/upload/open_upload_manager.go delete mode 100644 internal/mcp/upload/upload_ipfs_app.go delete mode 100644 internal/mcp/upload/upload_tools.go rename internal/mcp/{upload_ipfs_app_test.go => upload_manager_app_test.go} (96%) diff --git a/go.mod b/go.mod index 1972f2a3..74573488 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-20260912164012-1fb1c2629a68 + go.lumeweb.com/pinner v0.0.0-20260912182741-cd1c1d950ad1 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 e24ef398..19dfec36 100644 --- a/go.sum +++ b/go.sum @@ -753,8 +753,10 @@ 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-20260912164012-1fb1c2629a68 h1:mdevpaq9foPQD4usfy88eUxQRqGWOF6WU8ATjDhtL2E= -go.lumeweb.com/pinner v0.0.0-20260912164012-1fb1c2629a68/go.mod h1:ni3gEeGNpTZ5xOwtO9p87G0yChjaEeSTqVId4n2b5pY= +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/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/adapter_tunnel.go b/internal/mcp/adapter_tunnel.go index b132451c..1cd6dc52 100644 --- a/internal/mcp/adapter_tunnel.go +++ b/internal/mcp/adapter_tunnel.go @@ -21,6 +21,7 @@ import ( "go.lumeweb.com/mcpplane/session" mcptransfer "go.lumeweb.com/mcpplane/transfer" oauthlib "go.lumeweb.com/oauth" + "go.lumeweb.com/pinner-cli/internal/mcp/apps" "go.lumeweb.com/pinner-cli/internal/mcp/auth" "go.lumeweb.com/pinner-cli/internal/mcp/core/handoff" "go.lumeweb.com/pinner-cli/internal/mcp/core/ieo" @@ -503,7 +504,7 @@ func serveHTTP(ctx context.Context, srv *sdk.Server, card *ServerCard, cmd *cli. // already live; this covers the listing-level static default that most hosts // actually use to build the iframe CSP. if curlUpload != nil { - if err := sdk.SetAppResourceConnectDomains(srv, upload.IPFSUploadAppURI, curlUpload.ConnectOrigins()); err != nil { + if err := sdk.SetAppResourceConnectDomains(srv, apps.UploadManagerAppURI(), curlUpload.ConnectOrigins()); err != nil { return err } } @@ -734,7 +735,7 @@ func serveHTTP(ctx context.Context, srv *sdk.Server, card *ServerCard, cmd *cli. // and captured HTML can be collected when this server is discarded, // rather than accumulating for the process lifetime. if curlUpload != nil { - _ = sdk.UnregisterAppResource(srv, upload.IPFSUploadAppURI) + _ = sdk.UnregisterAppResource(srv, apps.UploadManagerAppURI()) } if vaultUpload != nil { _ = sdk.UnregisterAppResource(srv, upload.VaultUploadAppURI) @@ -802,7 +803,7 @@ func serveHTTP(ctx context.Context, srv *sdk.Server, card *ServerCard, cmd *cli. // public origin, so re-bake the list-level connectDomains (last write // wins over the base block above) to that tunnel origin. if curlUpload != nil { - if err := sdk.SetAppResourceConnectDomains(srv, upload.IPFSUploadAppURI, curlUpload.ConnectOrigins()); err != nil { + if err := sdk.SetAppResourceConnectDomains(srv, apps.UploadManagerAppURI(), curlUpload.ConnectOrigins()); err != nil { shutdown(context.Background()) return err } diff --git a/internal/mcp/annotations_wire_test.go b/internal/mcp/annotations_wire_test.go index c00e9236..6894515a 100644 --- a/internal/mcp/annotations_wire_test.go +++ b/internal/mcp/annotations_wire_test.go @@ -298,8 +298,11 @@ func TestToolDetailAlwaysEmitsBoolHints(t *testing.T) { } // TestIPFSAppHelpersWireContract pins the directory requirements on the -// app-only upload helpers: boolean annotations, openai/toolInvocation labels, -// and non-empty descriptions on every params property. +// app-only upload helpers: boolean annotations and non-empty descriptions on +// every params property. The helper DESCRIPTORS are module-owned +// (go.lumeweb.com/pinner/mcp/appswire.UploadManagerHelpers), so the +// openai/toolInvocation invocation labels the CLI-local copies used to carry +// are not part of this contract anymore. func TestIPFSAppHelpersWireContract(t *testing.T) { srv, _ := buildIPFSUploadAppServer(t) cs := connectOfficialClient(t, srv) @@ -316,10 +319,6 @@ func TestIPFSAppHelpersWireContract(t *testing.T) { tool, ok := byName[name] require.True(t, ok, "%s registered on the wire", name) requireBoolHints(t, tool) - invocation, ok := tool.Meta["openai/toolInvocation"].(map[string]any) - require.True(t, ok, "%s: openai/toolInvocation metadata present", name) - require.NotEmpty(t, invocation["invoking"], "%s: present-tense invoking label", name) - require.NotEmpty(t, invocation["invoked"], "%s: past-tense invoked label", name) } // Every property of both helpers' input schemas carries a description. diff --git a/internal/mcp/apps/appswire_install.go b/internal/mcp/apps/appswire_install.go new file mode 100644 index 00000000..699edb9e --- /dev/null +++ b/internal/mcp/apps/appswire_install.go @@ -0,0 +1,63 @@ +package apps + +// This file adapts the shared module installers +// (go.lumeweb.com/pinner/mcp/appswire) onto the CLI's process-global registry +// adapter: appswire's InstallContext needs the *AppRegistry instance and a +// view-only render func — both composition-owned facts this package holds. + +import ( + mcpapps "go.lumeweb.com/mcpplane/apps" + sdk "go.lumeweb.com/mcpplane/sdk" + mcptransfer "go.lumeweb.com/mcpplane/transfer" + + "go.lumeweb.com/pinner/canvas" + "go.lumeweb.com/pinner/mcp/appswire" + + "go.lumeweb.com/pinner-cli/internal/mcpapp" +) + +// GlobalRegistry exposes the process-global app registry so shared module +// installers write view + tool→view state into the SAME registry this +// package's registration API wraps (never a sibling instance that would split +// the association state). +func GlobalRegistry() *mcpapps.AppRegistry { return registry } + +// RenderAppView adapts the CLI's RenderAppDoc to appswire's view-only +// RenderFunc shape. The document title is the shared table's ResourceTitle — +// the same canonical title this package's per-view render functions (and the +// hosted composition root) pass through, so CLI documents stay byte-identical +// across seams. An unknown view defers to RenderAppDoc's loud panic semantics. +func RenderAppView(view canvas.View) string { + for _, spec := range appswire.All() { + if spec.View == view { + return mcpapp.RenderAppDoc(view, spec.ResourceTitle) + } + } + return mcpapp.RenderAppDoc(view, string(view)) +} + +// UploadManagerAppURI returns the shared table's ui:// resource URI for the +// Upload to IPFS view — the single URI every connectDomains / unregister / +// resource-lookup call site uses, so the wire URI cannot drift from the table +// the launcher descriptor and view installer resolve against. The empty return +// covers a table that dropped the row (a build-time divergence). +func UploadManagerAppURI() string { + v, ok := appswire.SpecForLauncher(appswire.LauncherUploadManager) + if !ok { + return "" + } + return v.URI +} + +// InstallUploadManagerApp wires the shared Upload to IPFS app seam +// (appswire.UploadManagerInstaller): the module owns the view assembly, the +// CSP connectDomains from the coordinator's origins, and the submit/status +// helper tools; the CLI supplies the server, catalog, registry, and renderer. +func InstallUploadManagerApp(srv *sdk.Server, catalog AppCatalog, hp *mcptransfer.Upload) error { + return appswire.UploadManagerInstaller(hp)(appswire.InstallContext{ + Server: srv, + Catalog: catalog, + Registry: registry, + Render: RenderAppView, + }) +} diff --git a/internal/mcp/core/transfer/presign_ttl.go b/internal/mcp/core/transfer/presign_ttl.go deleted file mode 100644 index afa73f17..00000000 --- a/internal/mcp/core/transfer/presign_ttl.go +++ /dev/null @@ -1,38 +0,0 @@ -package transfer - -import ( - "fmt" - "strings" - "time" -) - -// Canonical presigned-PUT TTL parsing for EVERY presigned-upload surface -// (upload_file remote mint, vault_put_file mint, the IPFS/Vault upload App -// helpers, the open_upload_manager / open_vault_manager launchers). This is -// the ONE parser each of those paths must use so the accepted wire format, -// the default, the non-positive handling, and the error wording cannot drift -// between the tool, launcher, and app-only helper surfaces: -// -// - empty (or whitespace) input yields DefaultHTTPUploadTTL; -// - unparseable input yields a wrapped time.ParseDuration error with the -// stable wording `invalid ttl "..."` (callers surface it as-is); -// - a parsed non-positive duration is NOT an error: it falls back to -// DefaultHTTPUploadTTL, matching the coordinator-side clamping (an -// explicit "0s" or "-1m" gets the documented default lifetime rather than -// an unusable endpoint). -// -// Callers keep their downstream `ttl <= 0 → default` clamps as defense in -// depth only; they must not re-implement the parsing. -func ParsePresignTTL(raw string) (time.Duration, error) { - if strings.TrimSpace(raw) == "" { - return DefaultHTTPUploadTTL, nil - } - d, err := time.ParseDuration(strings.TrimSpace(raw)) - if err != nil { - return 0, fmt.Errorf("invalid ttl %q: %w", raw, err) - } - if d <= 0 { - return DefaultHTTPUploadTTL, nil - } - return d, nil -} diff --git a/internal/mcp/core/transfer/presign_ttl_test.go b/internal/mcp/core/transfer/presign_ttl_test.go deleted file mode 100644 index 03334cb7..00000000 --- a/internal/mcp/core/transfer/presign_ttl_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package transfer - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// TestParsePresignTTL pins the ONE canonical presigned-PUT TTL parser every -// presigned surface (upload_file remote mint, vault_put_file mint, the app -// helpers, the open_upload_manager / open_vault_manager launchers) must use: -// - empty/whitespace input → DefaultHTTPUploadTTL; -// - unparseable input → wrapped error with the stable wording -// `invalid ttl "..."`; -// - non-positive input (0s, negative) → DefaultHTTPUploadTTL (NOT an error), -// matching the coordinator-side clamping. -func TestParsePresignTTL(t *testing.T) { - t.Run("empty defaults", func(t *testing.T) { - d, err := ParsePresignTTL("") - require.NoError(t, err) - require.Equal(t, DefaultHTTPUploadTTL, d) - }) - - t.Run("whitespace defaults", func(t *testing.T) { - d, err := ParsePresignTTL(" ") - require.NoError(t, err) - require.Equal(t, DefaultHTTPUploadTTL, d) - }) - - t.Run("valid duration passes through", func(t *testing.T) { - d, err := ParsePresignTTL("5m") - require.NoError(t, err) - require.Equal(t, 5*time.Minute, d) - }) - - t.Run("padded duration passes through", func(t *testing.T) { - // The doc claims whitespace tolerance: non-empty padded input must be - // trimmed before parsing, not rejected. - d, err := ParsePresignTTL(" 5m ") - require.NoError(t, err) - require.Equal(t, 5*time.Minute, d) - }) - - t.Run("fractional duration passes through", func(t *testing.T) { - d, err := ParsePresignTTL("90s") - require.NoError(t, err) - require.Equal(t, 90*time.Second, d) - }) - - t.Run("zero falls back to default", func(t *testing.T) { - d, err := ParsePresignTTL("0s") - require.NoError(t, err) - require.Equal(t, DefaultHTTPUploadTTL, d) - }) - - t.Run("negative falls back to default", func(t *testing.T) { - d, err := ParsePresignTTL("-1m") - require.NoError(t, err) - require.Equal(t, DefaultHTTPUploadTTL, d) - }) - - t.Run("unparseable yields stable invalid ttl wording", func(t *testing.T) { - _, err := ParsePresignTTL("soon") - require.Error(t, err) - require.Contains(t, err.Error(), `invalid ttl "soon"`) - }) -} diff --git a/internal/mcp/core/transfer/upload_file.go b/internal/mcp/core/transfer/upload_file.go index 382d4df6..35ea369e 100644 --- a/internal/mcp/core/transfer/upload_file.go +++ b/internal/mcp/core/transfer/upload_file.go @@ -9,6 +9,8 @@ import ( "github.com/invopop/jsonschema" + pinnertransfer "go.lumeweb.com/pinner/transfer" + "go.lumeweb.com/pinner-cli/internal/mcp/core/ieo" "go.lumeweb.com/pinner-cli/internal/mcp/mintcontract" "go.lumeweb.com/pinner-cli/internal/mcp/schematext" @@ -257,12 +259,12 @@ func newUploadFileDescriptor(features hostenv.FeatureSet, coLocated, tunnelOpenA if name == "" { name = DefaultUploadName } - // The ONE shared presign-TTL parser (ParsePresignTTL below in - // this package): empty → default, non-positive → default, - // unparseable → the stable `invalid ttl "..."` wording. The - // coordinator-side clamp inside Prepare remains as defense in - // depth only. - ttl, terr := ParsePresignTTL(in.TTL) + // The ONE canonical presign-TTL parser + // (go.lumeweb.com/pinner/transfer.ParsePresignTTL): empty → + // default, non-positive → default, unparseable → the stable + // `invalid ttl "..."` wording. The coordinator-side clamp + // inside Prepare remains as defense in depth only. + ttl, terr := pinnertransfer.ParsePresignTTL(in.TTL) if terr != nil { return model.ToolResult{}, terr } diff --git a/internal/mcp/custom_registration_inventory_test.go b/internal/mcp/custom_registration_inventory_test.go index e70cb5fb..3ad98d9f 100644 --- a/internal/mcp/custom_registration_inventory_test.go +++ b/internal/mcp/custom_registration_inventory_test.go @@ -51,6 +51,8 @@ import ( oobpkg "go.lumeweb.com/pinner-cli/internal/mcp/oob" uploadpkg "go.lumeweb.com/pinner-cli/internal/mcp/upload" vaultpkg "go.lumeweb.com/pinner-cli/internal/mcp/vault" + + "go.lumeweb.com/pinner/mcp/appswire" ) // stubUploadExec is a minimal-but-real UploadHandler (the shared shape behind @@ -280,7 +282,7 @@ func openLauncherToolNames(t *testing.T) []string { return []string{ apps.OpenPinCreatorToolName, downloadpkg.OpenPinListToolName, - uploadpkg.OpenUploadManagerToolName, + appswire.LauncherUploadManager, uploadpkg.OpenVaultManagerToolName, auth.OpenSSOSigninToolName, auth.OpenAccountToolName, diff --git a/internal/mcp/custom_tools.go b/internal/mcp/custom_tools.go index e935a4d2..5ec60c8e 100644 --- a/internal/mcp/custom_tools.go +++ b/internal/mcp/custom_tools.go @@ -21,6 +21,7 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" "go.lumeweb.com/pinner-cli/internal/mcp/upload" "go.lumeweb.com/pinner-cli/internal/mcp/vault" + pinnermcp "go.lumeweb.com/pinner/mcp" "go.lumeweb.com/pinner/mcp/appswire" ) @@ -583,8 +584,17 @@ func collectServerExtensions(deps customToolDeps) (*MaterializationPlan, error) uploadFileSpec.roles = append(uploadFileSpec.roles, roleCatalogSearch) } if transfer.SinkDropReachable(deps.curlUpload != nil, deps.tunnelOpenAI) { - reg.add(appLauncherSpec(upload.NewOpenUploadManagerDescriptor(deps.curlUpload), func(srv *sdk.Server, catalog apps.AppCatalog) error { - return upload.RegisterIPFSUploadApp(srv, catalog, deps.curlUpload) + // Shared seam (go.lumeweb.com/pinner/mcp/appswire): the launcher + // descriptor and the dependency-bound installer are module-owned; + // the CLI supplies the render func and the process-global app + // registry. A descriptor build error is a wiring bug at this seam + // (same policy as addLauncher) and fails the assembly hard. + desc, err := appswire.UploadManagerDescriptor(deps.curlUpload) + if err != nil { + return nil, err + } + reg.add(appLauncherSpec(desc, func(srv *sdk.Server, catalog apps.AppCatalog) error { + return apps.InstallUploadManagerApp(srv, catalog, deps.curlUpload) })) } reg.add(uploadFileSpec) @@ -593,8 +603,14 @@ func collectServerExtensions(deps customToolDeps) (*MaterializationPlan, error) // --- Async upload management tools (upload_status / upload_cancel / upload_list) --- // These are search-only: the agent_guide upload flow names upload_status // as a step, so an agent following the guide discovers it via search_tools. + // The descriptors are module-owned (go.lumeweb.com/pinner/mcp), so this + // surface cannot drift from the hosted assembly's. Descriptors without + // MCPTargets get the universal Fallback wrap in ToolCatalog.Add. + // upload_list is registered unconditionally here: this CLI server is a + // single-user, same-process manager — the per-principal-manager case the + // module's AsyncUploadList opt-in reserves the enumerator for. if uploadOn && opts.uploadTasks != nil { - for _, desc := range upload.NewAsyncUploadTools(opts.uploadTasks) { + for _, desc := range pinnermcp.NewAsyncUploadTools(opts.uploadTasks) { reg.add(searchableOnly(desc)) } } diff --git a/internal/mcp/hosted.go b/internal/mcp/hosted.go index 8fc3b993..33b283cf 100644 --- a/internal/mcp/hosted.go +++ b/internal/mcp/hosted.go @@ -10,7 +10,6 @@ import ( "go.lumeweb.com/mcpplane/transfer" "go.lumeweb.com/pinner-cli/internal/mcp/apps" "go.lumeweb.com/pinner-cli/internal/mcp/core/ieo" - "go.lumeweb.com/pinner-cli/internal/mcp/upload" ) // httpsOriginOf returns the exact HTTPS origin (scheme://host[:port], no @@ -228,7 +227,7 @@ func buildHostedServer(cfg HostedServerConfig) (*sdk.Server, *ToolCatalog, *Host // coordinator's origin to that resource's connectDomains (the resource URI is // otherwise mounted as a static default that most hosts use for their CSP). if hostedTransfer != nil && hostedTransfer.Upload != nil { - if err := sdk.SetAppResourceConnectDomains(srv, upload.IPFSUploadAppURI, hostedTransfer.Upload.ConnectOrigins()); err != nil { + if err := sdk.SetAppResourceConnectDomains(srv, apps.UploadManagerAppURI(), hostedTransfer.Upload.ConnectOrigins()); err != nil { return nil, nil, nil, err } } diff --git a/internal/mcp/hosted_concurrency_test.go b/internal/mcp/hosted_concurrency_test.go index 04c5a7d7..073bb35b 100644 --- a/internal/mcp/hosted_concurrency_test.go +++ b/internal/mcp/hosted_concurrency_test.go @@ -12,7 +12,6 @@ import ( "go.lumeweb.com/mcpplane/sdk" mcptransfer "go.lumeweb.com/mcpplane/transfer" "go.lumeweb.com/pinner-cli/internal/mcp/apps" - "go.lumeweb.com/pinner-cli/internal/mcp/upload" ) // TestBuildHostedServerConcurrentViewDomainAssemblies is the concurrency @@ -68,7 +67,7 @@ func TestBuildHostedServerConcurrentViewDomainAssemblies(t *testing.T) { require.NoErrorf(t, err, "assembly %d list resources", i) var uploadView *mcp.Resource for _, r := range res.Resources { - if r.URI == upload.IPFSUploadAppURI { + if r.URI == apps.UploadManagerAppURI() { uploadView = r break } @@ -128,7 +127,7 @@ func TestBuildHostedServerEmptyOriginSerializedAgainstResolver(t *testing.T) { res, err := cs.ListResources(context.Background(), nil) require.NoErrorf(t, err, "assembly %d list resources (%s)", i, what) for _, r := range res.Resources { - if r.URI == upload.IPFSUploadAppURI { + if r.URI == apps.UploadManagerAppURI() { ui, ok := r.Meta["ui"].(map[string]any) require.Truef(t, ok, "assembly %d upload view carries _meta.ui (%s)", i, what) return ui diff --git a/internal/mcp/hosted_test.go b/internal/mcp/hosted_test.go index 5da60b39..8dc54000 100644 --- a/internal/mcp/hosted_test.go +++ b/internal/mcp/hosted_test.go @@ -13,7 +13,6 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/apps" "go.lumeweb.com/pinner-cli/internal/mcp/auth" "go.lumeweb.com/pinner-cli/internal/mcp/core/transfer" - "go.lumeweb.com/pinner-cli/internal/mcp/upload" corevault "go.lumeweb.com/pinner/core/vault" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -154,7 +153,7 @@ func TestBuildHostedServerViewDomainScopedToAssembly(t *testing.T) { require.NoError(t, err) var uploadView *mcp.Resource for _, r := range res.Resources { - if r.URI == upload.IPFSUploadAppURI { + if r.URI == apps.UploadManagerAppURI() { uploadView = r break } diff --git a/internal/mcp/mcptargets_test.go b/internal/mcp/mcptargets_test.go index 82d4e346..b6480cbe 100644 --- a/internal/mcp/mcptargets_test.go +++ b/internal/mcp/mcptargets_test.go @@ -15,6 +15,8 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" "go.lumeweb.com/pinner-cli/internal/mcp/upload" "go.lumeweb.com/pinner-cli/internal/mcp/vault" + + "go.lumeweb.com/pinner/mcp/appswire" ) // requireMCPTargets asserts a tool descriptor declares a per-profile MCP target @@ -41,8 +43,10 @@ func TestToolRegistrationsCarryMCPTargets(t *testing.T) { requireMCPTargets(t, vault.NewVaultGetFileDescriptor(nil, nil, "", 0, false)) requireMCPTargets(t, upload.RelayURLUploadDescriptor(nil, nil, 0)) - // Upload-manager launchers. - requireMCPTargets(t, upload.NewOpenUploadManagerDescriptor(nil)) + // Upload-manager launchers (module- and CLI-owned). + sumDesc, err := appswire.UploadManagerDescriptor(nil) + require.NoError(t, err) + requireMCPTargets(t, sumDesc) requireMCPTargets(t, upload.NewOpenVaultManagerDescriptor(nil)) // UI launchers (the apps.NewOpenLauncherDescriptor path covers every diff --git a/internal/mcp/upload/launcher_description_test.go b/internal/mcp/upload/launcher_description_test.go index 81efd463..f3ced782 100644 --- a/internal/mcp/upload/launcher_description_test.go +++ b/internal/mcp/upload/launcher_description_test.go @@ -5,21 +5,28 @@ import ( "github.com/stretchr/testify/require" + appswire "go.lumeweb.com/pinner/mcp/appswire" + "go.lumeweb.com/pinner-cli/internal/mcp/mintcontract" ) // TestLauncherDescriptionsPinnedWording pins the user-facing wording of the -// upload-package launcher descriptions (composed through the shared -// apps.OpenLauncherDescriptionBody skeleton, with the vault body carrying the -// canonical mintcontract durability fragments) so the wire text stays stable -// across refactors. +// Upload to IPFS launcher (module-owned via appswire.UploadManagerDescriptor; +// the CLI-local copy was removed with LumeWeb/pinner#54) and the remaining +// CLI-owned vault launcher description (composed through +// apps.OpenLauncherDescriptionBody with the canonical mintcontract durability +// fragments) so the wire text stays stable across refactors. func TestLauncherDescriptionsPinnedWording(t *testing.T) { require.Equal(t, "Open the interactive Upload to IPFS file picker. This is a UI launcher: it renders an iframe for a human to pick a file. "+ "It is not a headless primitive. Pass an optional 'handle' from a prior upload_file mint call to continue that exact operation; if the handle is stale/expired a fresh one is prepared. "+ - "Returns an upload_handle; "+mintcontract.UploadMintPoll+". "+ + "Returns an upload_handle; " + appswire.UploadMintPoll + " (the completed CID is already pinned). "+ "The headless equivalent is upload_file for autonomous uploads without a rendered file picker.", - openUploadManagerDescription) + func() string { + desc, err := appswire.UploadManagerDescriptor(nil) + require.NoError(t, err) + return desc.Description + }()) require.Equal(t, "Open the interactive Upload to Vault file picker. This is a UI launcher: it renders an iframe for a human to pick a file. "+ diff --git a/internal/mcp/upload/open_upload_manager.go b/internal/mcp/upload/open_upload_manager.go deleted file mode 100644 index 7897af68..00000000 --- a/internal/mcp/upload/open_upload_manager.go +++ /dev/null @@ -1,124 +0,0 @@ -package upload - -import ( - "context" - - "go.lumeweb.com/mcpplane/model" - "go.lumeweb.com/mcpplane/sdk" - "go.lumeweb.com/mcpplane/toolargs" - "go.lumeweb.com/mcpplane/transfer" - "go.lumeweb.com/pinner-cli/internal/mcp/apps" - coretransfer "go.lumeweb.com/pinner-cli/internal/mcp/core/transfer" - "go.lumeweb.com/pinner-cli/internal/mcp/mintcontract" - "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" -) - -// OpenUploadManagerURI is the ui:// resource URI served by the Upload to IPFS -// app. The launcher's tool _meta.ui references it. -const OpenUploadManagerURI = IPFSUploadAppURI - -// OpenUploadManagerToolName is the model-facing open_* launcher for the Upload -// to IPFS app. It is the ONLY tool carrying ui.resourceUri for this view; the -// headless upload_file primitive never advertises a card. -const OpenUploadManagerToolName = "open_upload_manager" - -// openUploadManagerDescription is shared between the static Description and -// the Fallback MCPTarget so the launcher descriptor carries a target list. It -// is composed from the shared launcher skeleton -// (apps.OpenLauncherDescriptionBody) so the scaffold wording cannot drift from -// the other open_* launchers. -var openUploadManagerDescription = apps.OpenLauncherDescriptionBody("Upload to IPFS file picker", "pick a file", - // The returned-handle/poll tail composes the canonical mintcontract - // UploadMintPoll (which tool, with which handle, until which terminal - // status) — never a paraphrase that drops the completed contract. - "Pass an optional 'handle' from a prior upload_file mint call to continue that exact operation; if the handle is stale/expired a fresh one is prepared. "+ - "Returns an upload_handle; "+mintcontract.UploadMintPoll+".", - "upload_file for autonomous uploads without a rendered file picker") - -// OpenUploadManagerInput is the typed argument shape for the model-facing -// Upload to IPFS launcher. handle is optional: when provided, the launcher -// opens the Upload to IPFS app pre-bound to that already-prepared upload -// operation so the user can pick a file to fulfill it; when empty (or when the -// handle is stale/expired), the launcher prepares a fresh operation itself. -type OpenUploadManagerInput struct { - Handle string `json:"handle,omitempty" jsonschema:"description=Optional upload handle from a prior upload_file mint result."` - // TTL's tag composes schematext.TTLLauncherFresh (struct tags cannot embed - // constants; TestSchemaTextFragmentsPinned pins this literal to it). - TTL string `json:"ttl,omitempty" jsonschema:"description=Optional presigned endpoint lifetime, e.g. 5m (default 5m). Only used when a fresh operation is prepared."` -} - -// NewOpenUploadManagerDescriptor builds the model-facing Upload to IPFS -// launcher tool. It is the ONLY tool that carries _meta.ui.resourceUri for -// this particular Upload App — upload_file itself is headless, so agents -// calling upload_file mid-workflow never see a UI render. -// -// The returned handle is the shared UploadTaskManager handle: pass it to -// upload_status for the final CID. When the caller supplies a handle, the -// launcher continues that operation (the canonical Prepare/Fulfill pattern: -// whoever supplies bytes first is the authoritative single result); otherwise -// the launcher mints a fresh operation. -func NewOpenUploadManagerDescriptor(hp *transfer.Upload) model.ToolDescriptor { - appMeta, _ := sdk.MarshalToolMeta(model.AppToolMeta{ - ResourceURI: OpenUploadManagerURI, - Visibility: []model.ToolVisibility{model.ToolVisibilityModel, model.ToolVisibilityApp}, - }) - return model.ToolDescriptor{ - Name: "open_upload_manager", - Title: "Open Upload to IPFS App", - Description: openUploadManagerDescription, - MCPTargets: toolforge.MCPTargets(toolforge.Fallback(openUploadManagerDescription)), - InputSchema: toolargs.ToolSchemaFor[OpenUploadManagerInput](), - Meta: appMeta, - Handler: func(ctx context.Context, request model.ToolRequest) (model.ToolResult, error) { - in, err := toolargs.DecodeToolArgs[OpenUploadManagerInput](request) - if err != nil { - return model.ToolResult{}, err - } - // ONE shared presign-TTL parser (core/transfer.ParsePresignTTL): - // empty → default, non-positive → default, unparseable → wrapped - // error with the stable `invalid ttl "..."` wording. - ttl, terr := coretransfer.ParsePresignTTL(in.TTL) - if terr != nil { - return model.ToolResult{}, terr - } - // continued indicates we fulfilled the caller's EXISTING operation. - // It is true only when the supplied handle resolved to a live - // endpoint; a stale/expired/used handle falls back to a fresh mint - // and is therefore a brand-new operation (continued=false). - var handle, url string - continued := false - if in.Handle != "" { - // Try to continue the caller's already-prepared operation - // (canonical Prepare/Fulfill — the picker fulfills the same - // task, not a sibling). If the handle is stale/expired/used, - // FindUpload misses; falling back to a fresh mint guarantees - // the picker always has a presigned URL to PUT to rather than - // opening a dead editor with no endpoint. - if resolved, ok := hp.FindUpload(in.Handle); ok { - url = resolved - handle = in.Handle - continued = true - } else { - url, handle = hp.Prepare(ctx, transfer.DefaultUploadName, ttl) - } - } else { - // Fresh operation: mint a presigned endpoint AND its canonical - // upload_handle so the app picker continues this exact task. - url, handle = hp.Prepare(ctx, transfer.DefaultUploadName, ttl) - } - if url == "" || handle == "" { - return model.ToolResult{}, coretransfer.ErrUploadPrepare - } - sc := map[string]any{ - "upload_handle": handle, - coretransfer.UploadHandlePollKey: coretransfer.UploadStatusTool, - "presigned_url": url, - "continued": continued, - } - return model.ToolResult{ - StructuredContent: sc, - Text: toolargs.ResultJSONText(sc) + " The Upload to IPFS UI is open; pick a file to upload. Poll upload_status with the handle for the CID.", - }, nil - }, - } -} diff --git a/internal/mcp/upload/open_vault_manager.go b/internal/mcp/upload/open_vault_manager.go index 0c0eb302..fd29fb19 100644 --- a/internal/mcp/upload/open_vault_manager.go +++ b/internal/mcp/upload/open_vault_manager.go @@ -12,6 +12,7 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/mintcontract" "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" corevault "go.lumeweb.com/pinner/core/vault" + pinnertransfer "go.lumeweb.com/pinner/transfer" ) // OpenVaultManagerURI is the ui:// resource URI served by the Upload to Vault @@ -114,10 +115,11 @@ func NewOpenVaultManagerDescriptor(vu *transfer.VaultHTTPUpload) model.ToolDescr if gr, failed := vaultProfileGuard(in.Profile); failed { return gr, nil } - // ONE shared presign-TTL parser (core/transfer.ParsePresignTTL) — - // the launcher must accept exactly the TTL format the upload_file - // mint path and the app helpers accept. - ttl, terr := transfer.ParsePresignTTL(in.TTL) + // ONE canonical presign-TTL parser + // (go.lumeweb.com/pinner/transfer.ParsePresignTTL) — the launcher + // must accept exactly the TTL format the upload_file mint path + // and the app helpers accept. + ttl, terr := pinnertransfer.ParsePresignTTL(in.TTL) if terr != nil { return model.ToolResult{}, terr } diff --git a/internal/mcp/upload/schema_text_shared_test.go b/internal/mcp/upload/schema_text_shared_test.go index d04c96e6..a6f57c54 100644 --- a/internal/mcp/upload/schema_text_shared_test.go +++ b/internal/mcp/upload/schema_text_shared_test.go @@ -24,12 +24,12 @@ func jsonschemaTagOf[T any](t *testing.T, field string) string { // TestSchemaTextFragmentsPinned pins every struct-tag TTL/profile description // in this package to its shared schematext fragment: the literals are the ONLY // non-composed copies, and this test makes their drift a build failure. -// The raw-JSON schemas (ipfs_upload_submit, vault_upload_submit) compose the -// schematext constants directly and need no pinning here. +// The IPFS upload surfaces (open_upload_manager launcher, ipfs_upload_submit +// helper) are module-owned (go.lumeweb.com/pinner/mcp/appswire) and pin their +// own schemas there — nothing left to pin in this package. The remaining +// raw-JSON schema (vault_upload_submit) composes the schematext constants +// directly and needs no pinning either. func TestSchemaTextFragmentsPinned(t *testing.T) { - // open_upload_manager: TTL applies only to fresh mints. - require.Contains(t, jsonschemaTagOf[OpenUploadManagerInput](t, "TTL"), schematext.TTLLauncherFresh) - // open_vault_manager launcher: optional TTL + full write-profile contract. require.Contains(t, jsonschemaTagOf[OpenVaultManagerInput](t, "TTL"), schematext.TTLOptional) require.Contains(t, jsonschemaTagOf[OpenVaultManagerInput](t, "Profile"), schematext.ProfileWriteExtended) @@ -38,9 +38,6 @@ func TestSchemaTextFragmentsPinned(t *testing.T) { require.Contains(t, jsonschemaTagOf[VaultUploadSubmitInput](t, "TTL"), schematext.TTLOptional) require.Contains(t, jsonschemaTagOf[VaultUploadSubmitInput](t, "Profile"), schematext.ProfileWriteExtended) - // ipfs_upload_submit app helper: TTL shape. - require.Contains(t, jsonschemaTagOf[IPFSUploadSubmitInput](t, "TTL"), schematext.TTLLifetime) - // The TTL fragments' derived default pins live ONCE in the schematext // package (TestTTLFragmentDefaultsPinned); this package pins only the // generated tags. diff --git a/internal/mcp/upload/upload_ipfs_app.go b/internal/mcp/upload/upload_ipfs_app.go deleted file mode 100644 index 76459dfb..00000000 --- a/internal/mcp/upload/upload_ipfs_app.go +++ /dev/null @@ -1,279 +0,0 @@ -package upload - -import ( - "context" - "encoding/json" - "fmt" - - planetransfer "go.lumeweb.com/mcpplane/transfer" - "go.lumeweb.com/pinner-cli/internal/mcpapp" - - "go.lumeweb.com/mcpplane/model" - "go.lumeweb.com/mcpplane/transfer" - coretransfer "go.lumeweb.com/pinner-cli/internal/mcp/core/transfer" - "go.lumeweb.com/pinner-cli/internal/mcp/schematext" - - "go.lumeweb.com/mcpplane/sdk" - "go.lumeweb.com/mcpplane/toolargs" - "go.lumeweb.com/pinner-cli/internal/mcp/apps" - "go.lumeweb.com/pinner/canvas" -) - -// This file wires the "Upload to IPFS" MCP App onto the shared AppView lib -// layer. It pairs the model-facing upload_file tool with a ui:// view that a -// UI-capable host renders as a file-picker panel. -// -// The view does NOT push file bytes through the MCP/LLM channel. There is no -// draft MCP file upload yet, so the app reuses the same out-of-band presigned -// PUT mechanism the agent uses with `curl -T`: an app-only helper mints a -// one-time presigned upload endpoint (the same Upload coordinator that -// backs upload_file), and the iframe's Uppy XHR uploader PUTs the raw file -// bytes straight to that endpoint. The 202 response carries an opaque -// upload_handle, which the app then hands to a second app-only helper that -// polls the shared UploadTaskManager (the same one backing upload_status) for -// the final CID. Credentials and auth never cross the MCP/LLM channel — only -// a URL and a handle do. - -// IPFSUploadAppURI is the ui:// resource serving the "Upload to IPFS" app. -const IPFSUploadAppURI = "ui://uploads/ipfs.html" - -// appPutPollTail is the ONE PUT-plus-poll instruction tail the app helpers -// append to their text-only JSON echo (submitted/fresh prepare both return -// the same contract: PUT the file bytes to the presigned url, then poll). -const appPutPollTail = " PUT the file bytes and poll for the CID." - -// IPFSUploadSubmitInput is the typed argument shape for the app-only -// ipfs_upload_submit helper. It continues or prepares a single canonical -// upload operation and returns the one-time presigned PUT endpoint bound to it; -// the retrieved URL is returned to the app so Uppy can XHR the file bytes to -// it. When the model-facing upload_file has already prepared an operation, the -// app passes its handle here so the file picker fulfills the SAME operation -// (no sibling upload is created). -type IPFSUploadSubmitInput struct { - // Handle is an optional canonical upload handle prepared by the - // model-facing upload_file tool. When given and still unfulfilled, submit - // returns the same endpoint+handle for that operation instead of minting a - // new one. When given but already claimed/completed, it reports the - // already-claimed state so the app just polls instead of re-uploading. - // Handle/Name carry no jsonschema description tags: this helper's - // schema is the LIVE, hand-authored InputSchema baked into - // ipfsUploadSubmitDescriptor (single description copy, tag/wire parity - // by construction). - Handle string `json:"handle,omitempty"` - // Name is the upload label (defaults to the source base name or 'upload'). - Name string `json:"name,omitempty"` - // TTL is the presigned endpoint lifetime. Its tag composes - // schematext.TTLLifetime (struct tags cannot embed constants; - // TestSchemaTextFragmentsPinned pins this literal to it). - TTL string `json:"ttl,omitempty" jsonschema:"description=Presigned endpoint lifetime (e.g. 5m; default 5m)."` -} - -// renderIPFSUploadAppHTML renders the complete "Upload to IPFS" app document -// (ui://uploads/ipfs.html). The shared shell (doctype//inline theme) and -// the ESM module (shared ext-apps bootstrap + upload logic) come from -// mcpapp.RenderAppDoc (go.lumeweb.com/pinner/canvas); the body form is authored in templ. -func renderIPFSUploadAppHTML() string { - return mcpapp.RenderAppDoc(canvas.ViewIPFSUpload, "Upload to IPFS") -} - -// ipfsUploadSubmitDescriptor builds the app-only prepare/continue helper for -// the Upload to IPFS view. It is visible to the app only (never the model). It -// returns a one-time presigned PUT URL bound to a canonical upload handle that -// the iframe's Uppy XHR uploader writes the file bytes to out of band — no -// bytes cross this tool or the LLM channel. -// -// When given a handle prepared by the model-facing upload_file, it CONTINUES -// that exact operation (returns the same URL + handle), so the App file picker -// fulfills the model's canonical upload instead of starting a sibling. When the -// handle is already claimed/completed it reports that explicitly so the app -// just polls. Without a handle it prepares a fresh canonical operation. -func ipfsUploadSubmitDescriptor(hp *transfer.Upload) model.ToolDescriptor { - return model.ToolDescriptor{ - Name: "ipfs_upload_submit", - Title: "Prepare a one-time upload endpoint", - Description: "Prepare (or continue) a one-time presigned HTTP PUT endpoint bound to a canonical upload handle; the app's Uppy XHR uploader writes file bytes to it out of band. Passing a handle prepared by upload_file fulfills that same operation. App-only helper for the Upload to IPFS view.", - InputSchema: json.RawMessage(`{"type":"object","properties":{"handle":{"type":"string","description":"Optional canonical upload handle prepared by upload_file; when given and still unfulfilled, the same endpoint+handle for that operation is returned instead of minting a new one."},"name":{"type":"string","description":"Optional upload name (defaults to the file name or 'upload')."},"ttl":{"type":"string","description":"` + schematext.TTLDurationString + `"}}}`), - // OpenAI tool invocation labels shown by UI-capable hosts while the - // tool runs and after it finishes. - Meta: map[string]any{ - "openai/toolInvocation": map[string]any{ - "invoking": "Preparing upload endpoint…", - "invoked": "Upload endpoint ready", - }, - }, - Handler: func(ctx context.Context, req model.ToolRequest) (model.ToolResult, error) { - in, err := toolargs.DecodeToolArgs[IPFSUploadSubmitInput](req) - if err != nil { - return model.ToolResult{}, err - } - // ONE shared presign-TTL parser (core/transfer.ParsePresignTTL), - // so the app helper accepts exactly the TTL format the tool and - // launcher surfaces accept (empty → default, non-positive → - // default, unparseable → wrapped `invalid ttl` error). - ttl, terr := coretransfer.ParsePresignTTL(in.TTL) - if terr != nil { - return model.ToolResult{}, terr - } - - // Continue an operation the model-facing upload_file already - // prepared: return the SAME endpoint + handle so the app fulfills - // the canonical operation rather than minting a sibling. - if in.Handle != "" { - if url, ok := hp.FindUpload(in.Handle); ok { - sc := map[string]any{ - "url": url, - "upload_handle": in.Handle, - "ttl": ttl.String(), - "max_bytes": hp.MaxBytes(), - "poll_tool": "ipfs_upload_status", - "continued": true, - "response_body": "the 202 body carries the SAME upload_handle; pass it to poll_tool", - } - return model.ToolResult{ - StructuredContent: sc, - Text: toolargs.ResultJSONText(sc) + appPutPollTail, - }, nil - } - // The task is still tracked, but its presigned endpoint is gone: - // either it was never fulfilled and its endpoint window lapsed, - // or it has already been claimed/completed. Distinguish the two - // so the app reacts correctly. - if task, terr := hp.Tasks().Get(in.Handle); terr == nil { - if task.State == transfer.UploadStatePrepared { - // Prepared but never fulfilled (endpoint pruned/timed - // out): this is NOT already-claimed — nobody supplied - // bytes. The app should prepare a fresh operation - // rather than trying to poll a byte-less handle or - // duplicating an in-flight upload. - return model.ToolResult{}, fmt.Errorf( - "upload %q was prepared but never fulfilled (endpoint expired); start a fresh upload", - in.Handle) - } - // Claimed or finished: report the already-claimed state so - // the app just polls and never re-uploads. - sc := map[string]any{ - "upload_handle": in.Handle, - "already_claimed": true, - "state": task.State, - "poll_tool": "ipfs_upload_status", - } - return model.ToolResult{ - StructuredContent: sc, - Text: toolargs.ResultJSONText(sc) + " This operation is already fulfilled/claimed; poll ipfs_upload_status for the CID.", - }, nil - } - return model.ToolResult{}, fmt.Errorf("unknown upload handle %q; start a fresh upload", in.Handle) - } - - // No handle: prepare a fresh canonical operation and return its - // handle up front (not only after the PUT) so the same handle can - // be polled via ipfs_upload_status. - name := in.Name - if name == "" { - // Module constant (go.lumeweb.com/mcpplane/transfer) — the - // same "upload" default the extracted library executors apply. - name = planetransfer.DefaultUploadName - } - url, handle := hp.Prepare(ctx, name, ttl) - if url == "" || handle == "" { - return model.ToolResult{}, coretransfer.ErrUploadPrepare - } - sc := map[string]any{ - "url": url, - "upload_handle": handle, - "ttl": ttl.String(), - "max_bytes": hp.MaxBytes(), - "poll_tool": "ipfs_upload_status", - "response_body": "the 202 body carries the SAME upload_handle; pass it to poll_tool", - } - return model.ToolResult{ - StructuredContent: sc, - // Text carries the same JSON so a text-only client sees the - // actual presigned URL plus poll instructions, not a stub. - Text: toolargs.ResultJSONText(sc) + appPutPollTail, - }, nil - }, - } -} - -// ipfsUploadStatusDescriptor builds the app-only poll helper for the Upload to -// IPFS view. It is visible to the app only (never the model). Given the opaque -// upload_handle returned by the presigned PUT's 202 body, it reports the async -// upload task state / CID from the shared UploadTaskManager (the same one that -// backs the model-facing upload_status tool). -func ipfsUploadStatusDescriptor(hp *transfer.Upload) model.ToolDescriptor { - return model.ToolDescriptor{ - Name: "ipfs_upload_status", - Title: "Get upload status", - Description: "Return the status of an async upload by handle: queued, running, completed (with CID), failed, or cancelled. App-only helper for the Upload to IPFS view.", - InputSchema: json.RawMessage(`{"type":"object","properties":{"handle":{"type":"string","description":"Opaque upload handle returned in the presigned upload's 202 response body."}},"required":["handle"]}`), - // OpenAI tool invocation labels shown by UI-capable hosts while the - // tool runs and after it finishes. - Meta: map[string]any{ - "openai/toolInvocation": map[string]any{ - "invoking": "Checking upload status…", - "invoked": "Upload status retrieved", - }, - }, - Handler: func(ctx context.Context, req model.ToolRequest) (model.ToolResult, error) { - in, err := toolargs.DecodeToolArgs[UploadHandleInput](req) - if err != nil { - return model.ToolResult{}, err - } - if in.Handle == "" { - return model.ToolResult{}, fmt.Errorf("handle is required") - } - task, err := hp.Tasks().Get(in.Handle) - if err != nil { - return model.ToolResult{}, err - } - return model.ToolResult{StructuredContent: task, Text: toolargs.ResultJSONText(task)}, nil - }, - } -} - -// RegisterIPFSUploadApp wires the complete "Upload to IPFS" MCP App: attaches -// the ui:// view to the upload_file tool, registers the ui://uploads/ipfs.html -// HTML resource, and registers the app-only mint and poll helpers. The -// Upload coordinator (`hp`) is the same one that backs upload_file's remote -// presigned mode, so a URL minted here feeds the same UploadTaskManager the -// poll helper reads — and the same one upload_status reads. -// -// The app only makes sense when a presigned upload coordinator is wired -// (remote HTTP/tunnel, or the ssh/stdio loopback), so registration requires a -// non-nil `hp`. -func RegisterIPFSUploadApp(srv *sdk.Server, catalog apps.AppCatalog, hp *transfer.Upload) error { - if srv == nil { - return fmt.Errorf("nil official server") - } - if catalog == nil { - return fmt.Errorf("nil tool catalog") - } - if hp == nil { - return fmt.Errorf("nil http upload coordinator") - } - return apps.RegisterAppView(srv, catalog, apps.AppView{ - URI: IPFSUploadAppURI, - Name: "ipfs-upload", - Title: "Upload to IPFS", - Description: "Pick a file and upload it to Pinner over IPFS.", - HTML: renderIPFSUploadAppHTML(), - PrefersBorder: true, - // Advertise the presigned upload origin in the app's CSP - // connectDomains so an MCP host permits the sandbox iframe to - // PUT file bytes to it. Resolved dynamically because the origin (the - // tunnel/base URL or loopback address) is only known after the server - // and transport are up — after app registration. - ConnectDomainsFunc: hp.ConnectOrigins, - // Attach the UI view to the open_upload_manager LAUNCHER — not the - // headless upload_file primitive (upload_file already stays headless - // by not copying catalog meta in custom_tools.go; pointing AttachTo at - // the launcher makes the separation explicit and keeps the catalog - // entry free of resourceUri). - AttachTo: []string{OpenUploadManagerToolName}, - Helpers: []model.ToolDescriptor{ - ipfsUploadSubmitDescriptor(hp), - ipfsUploadStatusDescriptor(hp), - }, - }) -} diff --git a/internal/mcp/upload/upload_tasks_test.go b/internal/mcp/upload/upload_tasks_test.go index 8030fd9f..31901a01 100644 --- a/internal/mcp/upload/upload_tasks_test.go +++ b/internal/mcp/upload/upload_tasks_test.go @@ -13,6 +13,7 @@ import ( "go.lumeweb.com/mcpplane/model" "go.lumeweb.com/mcpplane/transfer" + pinnermcp "go.lumeweb.com/pinner/mcp" ) func TestUploadTaskManagerLifecycle(t *testing.T) { @@ -129,7 +130,7 @@ func TestAsyncUploadToolsRegistered(t *testing.T) { mgr := transfer.NewUploadTaskManager(func(ctx context.Context, reader io.Reader, size int64, name string, wait bool, _ string, _ bool) (any, error) { return map[string]any{"cid": "QmB"}, nil }, 0) - descs := NewAsyncUploadTools(mgr) + descs := pinnermcp.NewAsyncUploadTools(mgr) require.Len(t, descs, 3) names := map[string]bool{} @@ -145,7 +146,7 @@ func TestAsyncUploadStatusToolMissingHandle(t *testing.T) { mgr := transfer.NewUploadTaskManager(func(ctx context.Context, reader io.Reader, size int64, name string, wait bool, _ string, _ bool) (any, error) { return nil, nil }, 0) - descs := NewAsyncUploadTools(mgr) + descs := pinnermcp.NewAsyncUploadTools(mgr) var status *model.ToolDescriptor for i := range descs { if descs[i].Name == "upload_status" { @@ -170,7 +171,7 @@ func TestAsyncUploadToolsTextCarriesData(t *testing.T) { return map[string]any{"cid": "QmText"}, nil } }, 0) - descs := NewAsyncUploadTools(mgr) + descs := pinnermcp.NewAsyncUploadTools(mgr) byName := map[string]model.ToolDescriptor{} for _, d := range descs { byName[d.Name] = d diff --git a/internal/mcp/upload/upload_tools.go b/internal/mcp/upload/upload_tools.go deleted file mode 100644 index cb45a4d6..00000000 --- a/internal/mcp/upload/upload_tools.go +++ /dev/null @@ -1,86 +0,0 @@ -package upload - -import ( - "context" - "fmt" - - "go.lumeweb.com/mcpplane/model" - "go.lumeweb.com/mcpplane/transfer" - "go.lumeweb.com/pinner-cli/internal/mcp/wizard" - - "go.lumeweb.com/mcpplane/toolargs" - "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" -) - -// UploadHandleInput is the typed argument shape for upload status/cancel tools. -type UploadHandleInput struct { - Handle string `json:"handle" jsonschema:"description=Opaque async upload handle returned by upload_file (mint mode)."` -} - -// NewAsyncUploadTools returns the upload-management tool descriptors backed by -// the given manager. These cover the status/cancel/list surface for handles -// created by upload_file's mint source mode. All tools are direct-registered -// so they are visible in tools/list. -func NewAsyncUploadTools(mgr *transfer.UploadTaskManager) []model.ToolDescriptor { - if mgr == nil { - return nil - } - return []model.ToolDescriptor{ - { - Name: "upload_status", - Title: "Get async upload status", - Description: "Return the current status of an async upload handle: queued, running, completed, failed, cancelled, or expired (the handle's presigned endpoint window lapsed before any bytes were supplied). Handles are created by upload_file when using source.mode=mint (presigned HTTP PUT).", - Category: model.CategoryCore, - MCPTargets: toolforge.MCPTargets(toolforge.Fallback("Return the current status of an async upload handle: queued, running, completed, failed, cancelled, or expired (the handle's presigned endpoint window lapsed before any bytes were supplied). Handles are created by upload_file when using source.mode=mint (presigned HTTP PUT).")), - InputSchema: toolargs.ToolSchemaFor[UploadHandleInput](), - Handler: func(ctx context.Context, request model.ToolRequest) (model.ToolResult, error) { - in, err := toolargs.DecodeToolArgs[UploadHandleInput](request) - if err != nil { - return model.ToolResult{}, err - } - if in.Handle == "" { - return model.ToolResult{}, fmt.Errorf("handle is required") - } - task, err := mgr.Get(in.Handle) - if err != nil { - return model.ToolResult{}, err - } - return model.ToolResult{StructuredContent: task, Text: toolargs.ResultJSONText(task)}, nil - }, - }, - { - Name: "upload_cancel", - Title: "Cancel an async upload", - Description: "Cancel a queued or running async upload by handle. Handles are created by upload_file when using source.mode=mint (presigned HTTP PUT).", - Category: model.CategoryCore, - MCPTargets: toolforge.MCPTargets(toolforge.Fallback("Cancel a queued or running async upload by handle. Handles are created by upload_file when using source.mode=mint (presigned HTTP PUT).")), - InputSchema: toolargs.ToolSchemaFor[UploadHandleInput](), - Handler: func(ctx context.Context, request model.ToolRequest) (model.ToolResult, error) { - in, err := toolargs.DecodeToolArgs[UploadHandleInput](request) - if err != nil { - return model.ToolResult{}, err - } - if in.Handle == "" { - return model.ToolResult{}, fmt.Errorf("handle is required") - } - if err := mgr.Cancel(in.Handle); err != nil { - return model.ToolResult{}, err - } - return model.ToolResult{StructuredContent: map[string]any{"handle": in.Handle, "cancelled": true}, Text: toolargs.ResultJSONText(map[string]any{"handle": in.Handle, "cancelled": true})}, nil - }, - }, - { - Name: "upload_list", - Title: "List async uploads", - Description: "List all tracked async upload handles and their current status. Handles are created by upload_file when using source.mode=mint (presigned HTTP PUT).", - Category: model.CategoryCore, - MCPTargets: toolforge.MCPTargets(toolforge.Fallback("List all tracked async upload handles and their current status. Handles are created by upload_file when using source.mode=mint (presigned HTTP PUT).")), - InputSchema: toolargs.ToolSchemaFor[wizard.NoInput](), - Handler: func(ctx context.Context, request model.ToolRequest) (model.ToolResult, error) { - tasks := mgr.List() - sc := map[string]any{"uploads": tasks} - return model.ToolResult{StructuredContent: sc, Text: toolargs.ResultJSONText(sc)}, nil - }, - }, - } -} diff --git a/internal/mcp/upload/upload_vault_app.go b/internal/mcp/upload/upload_vault_app.go index be5d55b3..409dbb98 100644 --- a/internal/mcp/upload/upload_vault_app.go +++ b/internal/mcp/upload/upload_vault_app.go @@ -15,6 +15,7 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/apps" "go.lumeweb.com/pinner-cli/internal/mcp/schematext" "go.lumeweb.com/pinner/canvas" + pinnertransfer "go.lumeweb.com/pinner/transfer" ) // This file wires the "Upload to Vault" MCP App onto the shared AppView lib @@ -97,12 +98,12 @@ func vaultUploadSubmitDescriptor(vu *transfer.VaultHTTPUpload) model.ToolDescrip if gr, failed := vaultProfileGuard(in.Profile); failed { return gr, nil } - // ONE shared presign-TTL parser (core/transfer.ParsePresignTTL): - // the previously divergent copy here accepted no-ttl without the - // default lifetime and used a different error wording. This - // matches the open_vault_manager launcher and vault_put_file mint - // path exactly (empty/non-positive → default lifetime). - ttl, terr := transfer.ParsePresignTTL(in.TTL) + // ONE canonical presign-TTL parser + // (go.lumeweb.com/pinner/transfer.ParsePresignTTL): empty/non- + // positive → the default lifetime, unparseable → the stable + // `invalid ttl "..."` error — the same parser the + // open_vault_manager launcher and vault_put_file mint path use. + ttl, terr := pinnertransfer.ParsePresignTTL(in.TTL) if terr != nil { return model.ToolResult{}, terr } diff --git a/internal/mcp/upload_file_openai_test.go b/internal/mcp/upload_file_openai_test.go index 037da064..80db9377 100644 --- a/internal/mcp/upload_file_openai_test.go +++ b/internal/mcp/upload_file_openai_test.go @@ -12,9 +12,10 @@ import ( "go.lumeweb.com/mcpplane/model" "go.lumeweb.com/mcpplane/sdk" mcptransfer "go.lumeweb.com/mcpplane/transfer" + "go.lumeweb.com/pinner-cli/internal/mcp/apps" "go.lumeweb.com/pinner-cli/internal/mcp/core/transfer" "go.lumeweb.com/pinner-cli/internal/mcp/hostenv" - "go.lumeweb.com/pinner-cli/internal/mcp/upload" + "go.lumeweb.com/pinner/mcp/appswire" ) // stdioFileDesc builds a co-located upload_file descriptor with a path handler @@ -109,8 +110,8 @@ func TestUploadFileOpenAIMetaCoexistsWithAppUI(t *testing.T) { desc := transfer.NewUploadFileDescriptor(hostenv.ProfileOpenAITunnel.Features, false, false, nil, cu, nil, nil, 0) catalog.Add(model.ToolEntryFromDescriptor(desc)) // Seed the launcher; the app's AttachTo now points at open_upload_manager. - seedLauncherForTest(t, srv, catalog, upload.OpenUploadManagerToolName, upload.OpenUploadManagerURI, model.CategoryCore) - require.NoError(t, upload.RegisterIPFSUploadApp(srv, catalog, cu)) + seedLauncherForTest(t, srv, catalog, appswire.LauncherUploadManager, apps.UploadManagerAppURI(), model.CategoryCore) + require.NoError(t, apps.InstallUploadManagerApp(srv, catalog, cu)) require.NoError(t, RegisterOfficialDescriptor(srv, desc)) require.NoError(t, RegisterOfficialDirectTools(srv, catalog)) @@ -142,7 +143,7 @@ func TestUploadFileOpenAIMetaCoexistsWithAppUI(t *testing.T) { require.NotNil(t, launchTool, "open_upload_manager must be in tools/list") lui, ok := launchTool.Meta["ui"].(map[string]any) require.True(t, ok, "_meta.ui must survive registration on the launcher") - require.Equal(t, upload.IPFSUploadAppURI, lui["resourceUri"]) + require.Equal(t, apps.UploadManagerAppURI(), lui["resourceUri"]) } // TestUploadFileOpenAIValidation is a table covering the deterministic diff --git a/internal/mcp/upload_ipfs_app_test.go b/internal/mcp/upload_manager_app_test.go similarity index 96% rename from internal/mcp/upload_ipfs_app_test.go rename to internal/mcp/upload_manager_app_test.go index b61eac63..16c710c4 100644 --- a/internal/mcp/upload_ipfs_app_test.go +++ b/internal/mcp/upload_manager_app_test.go @@ -16,8 +16,10 @@ import ( "go.lumeweb.com/mcpplane/model" "go.lumeweb.com/mcpplane/sdk" mcptransfer "go.lumeweb.com/mcpplane/transfer" + "go.lumeweb.com/pinner-cli/internal/mcp/apps" "go.lumeweb.com/pinner-cli/internal/mcp/core/transfer" - "go.lumeweb.com/pinner-cli/internal/mcp/upload" + pinnermcp "go.lumeweb.com/pinner/mcp" + "go.lumeweb.com/pinner/mcp/appswire" ) // buildIPFSUploadAppServer constructs the catalog + server the way the adapter @@ -52,9 +54,9 @@ func buildIPFSUploadAppServer(t *testing.T) (*mcp.Server, *mcptransfer.Upload) { // Seed the launcher via the TEST-ONLY registerOpenLauncher helper // (production routes launchers through the appLauncherSpec registry path); // the app's AttachTo now points at open_upload_manager, not upload_file. - seedLauncherForTest(t, srv, catalog, upload.OpenUploadManagerToolName, upload.OpenUploadManagerURI, model.CategoryCore) - if err := upload.RegisterIPFSUploadApp(srv, catalog, cu); err != nil { - t.Fatalf("upload.RegisterIPFSUploadApp: %v", err) + seedLauncherForTest(t, srv, catalog, appswire.LauncherUploadManager, apps.UploadManagerAppURI(), model.CategoryCore) + if err := apps.InstallUploadManagerApp(srv, catalog, cu); err != nil { + t.Fatalf("apps.InstallUploadManagerApp: %v", err) } if err := RegisterOfficialDescriptor(srv, uploadFileDesc); err != nil { t.Fatalf("RegisterOfficialDescriptor: %v", err) @@ -76,7 +78,7 @@ func TestRegisterIPFSUploadAppWire(t *testing.T) { } var foundRes bool for _, r := range res.Resources { - if r.URI == upload.IPFSUploadAppURI { + if r.URI == apps.UploadManagerAppURI() { foundRes = true } } @@ -119,8 +121,8 @@ func TestRegisterIPFSUploadAppWire(t *testing.T) { if !ok { t.Fatalf("_meta.ui missing on open_upload_manager: %T", launcherTool.Meta["ui"]) } - if got := lui["resourceUri"]; got != upload.IPFSUploadAppURI { - t.Fatalf("open_upload_manager _meta.ui.resourceUri = %#v, want %q", got, upload.IPFSUploadAppURI) + if got := lui["resourceUri"]; got != apps.UploadManagerAppURI() { + t.Fatalf("open_upload_manager _meta.ui.resourceUri = %#v, want %q", got, apps.UploadManagerAppURI()) } vis, _ := lui["visibility"].([]any) if len(vis) != 2 { @@ -263,8 +265,8 @@ func TestIPFSUploadPollHelper(t *testing.T) { func TestRegisterIPFSUploadAppNilCoordinator(t *testing.T) { srv := sdk.NewServer(nil) catalog := NewToolCatalog() - if err := upload.RegisterIPFSUploadApp(srv, catalog, nil); err == nil { - t.Fatalf("upload.RegisterIPFSUploadApp with nil coordinator must fail") + if err := apps.InstallUploadManagerApp(srv, catalog, nil); err == nil { + t.Fatalf("apps.InstallUploadManagerApp with nil coordinator must fail") } } @@ -477,9 +479,9 @@ func buildIPFSUploadSharedServer(t *testing.T) (*mcp.Server, *mcptransfer.Upload uploadFileDesc := transfer.NewUploadFileDescriptor(transportFeatures(false, false), false, false, nil, cu, nil, nil, 0) catalog.Add(model.ToolEntryFromDescriptor(uploadFileDesc)) // Seed the launcher; the app's AttachTo now points at open_upload_manager. - seedLauncherForTest(t, srv, catalog, upload.OpenUploadManagerToolName, upload.OpenUploadManagerURI, model.CategoryCore) - if err := upload.RegisterIPFSUploadApp(srv, catalog, cu); err != nil { - t.Fatalf("upload.RegisterIPFSUploadApp: %v", err) + seedLauncherForTest(t, srv, catalog, appswire.LauncherUploadManager, apps.UploadManagerAppURI(), model.CategoryCore) + if err := apps.InstallUploadManagerApp(srv, catalog, cu); err != nil { + t.Fatalf("apps.InstallUploadManagerApp: %v", err) } if err := RegisterOfficialDescriptor(srv, uploadFileDesc); err != nil { t.Fatalf("RegisterOfficialDescriptor(upload_file): %v", err) @@ -487,7 +489,7 @@ func buildIPFSUploadSharedServer(t *testing.T) (*mcp.Server, *mcptransfer.Upload // The model-facing upload_status/cancel/list tools (mirrors // custom_tools.go's NewAsyncUploadTools registration) share the same // manager. - for _, desc := range upload.NewAsyncUploadTools(mgr) { + for _, desc := range pinnermcp.NewAsyncUploadTools(mgr) { if err := RegisterOfficialDescriptor(srv, desc); err != nil { t.Fatalf("RegisterOfficialDescriptor(%s): %v", desc.Name, err) } @@ -814,7 +816,7 @@ func TestIPFSUploadResourceAdvertisesConnectDomains(t *testing.T) { readConnectDomains := func(t *testing.T) []any { t.Helper() - res, err := cs.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: upload.IPFSUploadAppURI}) + res, err := cs.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: apps.UploadManagerAppURI()}) if err != nil { t.Fatalf("ReadResource: %v", err) } @@ -869,7 +871,10 @@ func TestOpenUploadManagerStaleHandleFallsBack(t *testing.T) { cu := mcptransfer.NewHTTPUpload(mgr, 1<<20) t.Cleanup(func() { cu.Stop(context.Background()) }) - desc := upload.NewOpenUploadManagerDescriptor(cu) + desc, err := appswire.UploadManagerDescriptor(cu) + if err != nil { + t.Fatalf("UploadManagerDescriptor: %v", err) + } srv := sdk.NewServer(nil) if err := RegisterOfficialDescriptor(srv, desc); err != nil { t.Fatalf("RegisterOfficialDescriptor(open_upload_manager): %v", err) diff --git a/internal/mcp/vault/vault_put_file.go b/internal/mcp/vault/vault_put_file.go index 05712d63..4d235199 100644 --- a/internal/mcp/vault/vault_put_file.go +++ b/internal/mcp/vault/vault_put_file.go @@ -18,6 +18,7 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/schematext" "go.lumeweb.com/pinner-cli/internal/mcp/toolforge" corevault "go.lumeweb.com/pinner/core/vault" + pinnertransfer "go.lumeweb.com/pinner/transfer" "go.lumeweb.com/mcpplane/model" @@ -268,11 +269,12 @@ func newVaultPutFileDescriptor(features hostenv.FeatureSet, coLocated, tunnelOpe if vu == nil { return model.ToolResult{}, errors.New("presigned vault-upload endpoint is not configured for remote mode") } - // ONE shared presign-TTL parser (core/transfer.ParsePresignTTL): - // empty → default, non-positive → default, unparseable → the - // stable `invalid ttl "..."` wording. The coordinator-side - // clamp inside Mint remains as defense in depth only. - ttl, terr := transfer.ParsePresignTTL(in.TTL) + // ONE shared presign-TTL parser + // (go.lumeweb.com/pinner/transfer.ParsePresignTTL): empty → + // default, non-positive → default, unparseable → the stable + // `invalid ttl "..."` wording. The coordinator-side clamp + // inside Mint remains as defense in depth only. + ttl, terr := pinnertransfer.ParsePresignTTL(in.TTL) if terr != nil { return model.ToolResult{}, terr } diff --git a/internal/mcp/wire_meta_test.go b/internal/mcp/wire_meta_test.go index 05bfe55a..ea868464 100644 --- a/internal/mcp/wire_meta_test.go +++ b/internal/mcp/wire_meta_test.go @@ -12,6 +12,8 @@ import ( "go.lumeweb.com/pinner-cli/internal/mcp/hostenv" "go.lumeweb.com/pinner-cli/internal/mcp/upload" "go.lumeweb.com/pinner-cli/internal/mcp/vault" + + "go.lumeweb.com/pinner/mcp/appswire" ) // TestWireMetaHeadlessPrimitives asserts that the headless operational tools @@ -67,7 +69,10 @@ func TestWireMetaHeadlessPrimitives(t *testing.T) { func TestWireMetaLaunchers(t *testing.T) { hp := mcptransfer.NewHTTPUpload(mcptransfer.NewUploadTaskManager(nil, 0), 0) - launcher := upload.NewOpenUploadManagerDescriptor(hp) + launcher, err := appswire.UploadManagerDescriptor(hp) + if err != nil { + t.Fatalf("UploadManagerDescriptor: %v", err) + } tool := sdk.Tool(launcher) if tool.Meta == nil { t.Fatal("open_upload_manager Meta is nil")