From abd16f87032c767d02a922afa74ad0b06c7e4996 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Mon, 5 Oct 2026 23:42:40 +0200 Subject: [PATCH] feat: add mcp support --- cmd/common.go | 4 + cmd/mcp.go | 86 +++++ cmd/mcp_app.go | 93 ++++++ cmd/mcp_app_test.go | 32 ++ cmd/mcp_test.go | 482 +++++++++++++++++++++++++++ cmd/mcp_tools.go | 311 +++++++++++++++++ cmd/root.go | 1 + go.mod | 7 + go.sum | 16 + pkg/appstore/appstore.go | 2 +- pkg/appstore/appstore_lookup.go | 10 +- pkg/appstore/appstore_lookup_test.go | 27 ++ 12 files changed, 1069 insertions(+), 2 deletions(-) create mode 100644 cmd/mcp.go create mode 100644 cmd/mcp_app.go create mode 100644 cmd/mcp_app_test.go create mode 100644 cmd/mcp_test.go create mode 100644 cmd/mcp_tools.go diff --git a/cmd/common.go b/cmd/common.go index 8a9258f9..b419e7ad 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -101,6 +101,10 @@ func initWithCommand(cmd *cobra.Command) { format := util.Must(OutputFormatFromString(cmd.Flag("format").Value.String())) dependencies.Logger = newLogger(format, verbose) + if cmd.Name() == "mcp" { + dependencies.Logger = log.NewLogger(log.Args{Verbose: verbose, Writer: zerolog.SyncWriter(os.Stderr)}) + } + dependencies.OS = operatingsystem.New() dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS}) stateDirectory := util.Must(prepareStateDirectory(dependencies.OS, dependencies.Machine.HomeDirectory())) diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 00000000..8075f69c --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +func mcpCmd() *cobra.Command { + return &cobra.Command{ + Use: "mcp", + Short: "Serve App Store tools over MCP using stdio", + Long: "Serve App Store tools over the Model Context Protocol using stdio.\n" + + "Authenticate with 'ipatool auth login' before using the tools.\n" + + "Runs non-interactively; authentication tools are not exposed.", + Args: cobra.NoArgs, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + cmd.SetContext(context.WithValue(cmd.Context(), interactiveKey, false)) + initWithCommand(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stop() + + server := newMCPServer(dependencies.AppStore) + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { + return fmt.Errorf("MCP server failed: %w", err) + } + + return nil + }, + } +} + +type mcpTools struct { + store appstore.AppStore + gate chan struct{} +} + +func newMCPServer(store appstore.AppStore) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "ipatool", Version: version}, &mcp.ServerOptions{ + Instructions: "Use the existing ipatool account. If authentication is required, ask the user to run ipatool auth login outside MCP. Purchases support free apps only.", + }) + tools := &mcpTools{store: store, gate: make(chan struct{}, 1)} + addMCPTool(server, tools, "search_apps", "Search for apps on the App Store.", true, tools.search) + addMCPTool(server, tools, "download_app", "Download an app package to the local filesystem. Optionally acquire a free license if purchase is true.", false, tools.download) + addMCPTool(server, tools, "list_app_versions", "List available external version identifiers for an app.", true, tools.listVersions) + addMCPTool(server, tools, "list_purchases", "List apps owned by the authenticated account, with pagination.", true, tools.listPurchases) + addMCPTool(server, tools, "purchase_app", "Obtain a license for a free app. Paid apps are not supported.", false, tools.purchase) + + return server +} + +func addMCPTool[In, Out any](server *mcp.Server, tools *mcpTools, name, description string, readOnly bool, handler func(context.Context, In) (Out, error)) { + mcp.AddTool(server, &mcp.Tool{ + Name: name, + Description: description, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly}, + }, func(ctx context.Context, _ *mcp.CallToolRequest, input In) (*mcp.CallToolResult, Out, error) { + var zero Out + + // The App Store client shares account state and caches across calls. + select { + case tools.gate <- struct{}{}: + defer func() { <-tools.gate }() + case <-ctx.Done(): + return nil, zero, fmt.Errorf("tool call canceled: %w", ctx.Err()) + } + + if err := ctx.Err(); err != nil { + return nil, zero, fmt.Errorf("tool call canceled: %w", err) + } + + output, err := handler(ctx, input) + if err != nil { + return nil, zero, fmt.Errorf("%s failed: %w", name, err) + } + + return nil, output, nil + }) +} diff --git a/cmd/mcp_app.go b/cmd/mcp_app.go new file mode 100644 index 00000000..3a410288 --- /dev/null +++ b/cmd/mcp_app.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "time" + + "github.com/majd/ipatool/v2/pkg/appstore" +) + +// mcpApp keeps every metadata field in the MCP response, including zero values. +// The App Store's JSON representation is also used by other callers, so its +// omission rules are left unchanged. +type mcpApp struct { + ID int64 `json:"trackId"` + BundleID string `json:"bundleId"` + Name string `json:"trackName"` + Version string `json:"version"` + Price float64 `json:"price"` + PurchaseDate *time.Time `json:"purchaseDate"` + Platforms []appstore.Platform `json:"platforms"` +} + +func newMCPApp(app appstore.App) mcpApp { + output := mcpApp{ + ID: app.ID, BundleID: app.BundleID, Name: app.Name, Version: app.Version, Price: app.Price, + Platforms: append([]appstore.Platform{}, app.Platforms...), + } + if !app.PurchaseDate.IsZero() { + output.PurchaseDate = &app.PurchaseDate + } + + return output +} + +// completeApp supplements sparse metadata without making catalog availability a +// prerequisite for operating on owned or delisted apps. +func (t *mcpTools) completeApp(acc appstore.Account, app appstore.App, platform appstore.Platform) appstore.App { + knownPlatforms := len(app.Platforms) > 0 && app.Platforms[0] != appstore.PlatformUnknown + if app.ID <= 0 || (app.BundleID != "" && app.Name != "" && app.Version != "" && knownPlatforms) { + return app + } + + // Purchase history can contain several platforms when no filter was supplied. + // Use an app's known platform for its catalog lookup in that case. + if platform == "" && len(app.Platforms) > 0 && app.Platforms[0] != appstore.PlatformUnknown { + platform = app.Platforms[0] + } + + out, err := t.store.Lookup(appstore.LookupInput{Account: acc, AppID: app.ID, Platform: platform}) + if err != nil || out.App.ID != app.ID { + return app + } + + if app.BundleID == "" { + app.BundleID = out.App.BundleID + } + + if app.Name == "" { + app.Name = out.App.Name + } + + if app.Version == "" { + app.Version = out.App.Version + } + + if len(out.App.Platforms) > 0 && (len(app.Platforms) == 0 || (len(app.Platforms) == 1 && app.Platforms[0] == appstore.PlatformUnknown)) { + app.Platforms = out.App.Platforms + } + + if app.Price == 0 { + app.Price = out.App.Price + } + + return app +} + +func (t *mcpTools) appResults(acc appstore.Account, apps []appstore.App, platform appstore.Platform) []mcpApp { + results := make([]mcpApp, 0, len(apps)) + for _, app := range apps { + results = append(results, newMCPApp(t.completeApp(acc, app, platform))) + } + + return results +} + +type mcpAppParameters struct { + AppID int64 `json:"app_id"` + BundleIdentifier string `json:"bundle_identifier"` + Platform appstore.Platform `json:"platform"` +} + +func appParameters(app appstore.App, platform appstore.Platform) mcpAppParameters { + return mcpAppParameters{AppID: app.ID, BundleIdentifier: app.BundleID, Platform: platform} +} diff --git a/cmd/mcp_app_test.go b/cmd/mcp_app_test.go new file mode 100644 index 00000000..1df8b1eb --- /dev/null +++ b/cmd/mcp_app_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "errors" + + "github.com/majd/ipatool/v2/pkg/appstore" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MCP app metadata", func() { + DescribeTable("preserves source data when catalog metadata cannot be used", func(catalog appstore.App, lookupErr error) { + original := appstore.App{ID: 123, Name: "Original name", Price: 2.99, Platforms: []appstore.Platform{appstore.PlatformUnknown}} + store := &fakeMCPAppStore{lookup: func(appstore.LookupInput) (appstore.LookupOutput, error) { + return appstore.LookupOutput{App: catalog}, lookupErr + }} + tools := &mcpTools{store: store} + + Expect(tools.completeApp(appstore.Account{}, original, "")).To(Equal(original)) + }, + Entry("delisted app", appstore.App{}, appstore.ErrAppNotFound), + Entry("network failure", appstore.App{}, errors.New("network failure")), + Entry("wrong app ID", appstore.App{ID: 999, BundleID: "wrong.bundle"}, nil), + Entry("missing catalog values", appstore.App{ID: 123}, nil), + ) + + It("avoids catalog lookups when app metadata is already complete", func() { + tools := &mcpTools{store: &fakeMCPAppStore{}} + app := appstore.App{ID: 123, BundleID: "com.example.app", Name: "Example", Version: "1.0", Platforms: []appstore.Platform{appstore.PlatformIPhone}} + Expect(tools.completeApp(appstore.Account{}, app, "")).To(Equal(app)) + }) +}) diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go new file mode 100644 index 00000000..fd91863c --- /dev/null +++ b/cmd/mcp_test.go @@ -0,0 +1,482 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/modelcontextprotocol/go-sdk/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MCP server", func() { + var ( + ctx context.Context + store *fakeMCPAppStore + session *mcp.ClientSession + account appstore.Account + app appstore.App + ) + + BeforeEach(func() { + var cancel context.CancelFunc + + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + DeferCleanup(cancel) + account = appstore.Account{Email: "private-email", Password: "private-password", PasswordToken: "private-token"} + app = appstore.App{ID: 123, BundleID: "com.example.app", Name: "Example"} + store = &fakeMCPAppStore{} + store.accountInfo = func() (appstore.AccountInfoOutput, error) { + return appstore.AccountInfoOutput{Account: account}, nil + } + store.lookup = func(appstore.LookupInput) (appstore.LookupOutput, error) { + return appstore.LookupOutput{}, appstore.ErrAppNotFound + } + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := newMCPServer(store).Connect(ctx, serverTransport, nil) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(serverSession.Close()).To(Succeed()) }) + session, err = mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, clientTransport, nil) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(session.Close()).To(Succeed()) }) + }) + + call := func(name string, args map[string]any) *mcp.CallToolResult { + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + Expect(err).NotTo(HaveOccurred()) + + return result + } + + It("registers the mcp subcommand", func() { + command, _, err := rootCmd().Find([]string{"mcp"}) + Expect(err).NotTo(HaveOccurred()) + Expect(command.Name()).To(Equal("mcp")) + Expect(command.Args(command, []string{"unexpected"})).NotTo(Succeed()) + }) + + It("advertises exactly five tools and their schemas without auth functions", func() { + result, err := session.ListTools(ctx, nil) + Expect(err).NotTo(HaveOccurred()) + names := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + names = append(names, tool.Name) + Expect(tool.InputSchema).NotTo(BeNil()) + Expect(tool.OutputSchema).NotTo(BeNil()) + Expect(tool.Annotations.ReadOnlyHint).To(Equal(tool.Name != "download_app" && tool.Name != "purchase_app")) + } + Expect(names).To(ConsistOf("search_apps", "download_app", "list_app_versions", "list_purchases", "purchase_app")) + }) + + It("searches with CLI defaults and returns structured app data without credentials", func() { + var input appstore.SearchInput + + store.search = func(in appstore.SearchInput) (appstore.SearchOutput, error) { + input = in + + return appstore.SearchOutput{Count: 1, Results: []appstore.App{app}}, nil + } + result := call("search_apps", map[string]any{"term": "example"}) + Expect(result.IsError).To(BeFalse()) + Expect(input).To(Equal(appstore.SearchInput{Account: account, Term: "example", Limit: 5})) + data, err := json.Marshal(result.StructuredContent) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("com.example.app")) + Expect(string(data)).NotTo(ContainSubstring("private-")) + Expect(result.Content).To(HaveLen(1)) + Expect(result.StructuredContent).To(HaveKeyWithValue("term", "example")) + Expect(result.StructuredContent).To(HaveKeyWithValue("limit", float64(5))) + Expect(result.StructuredContent).To(HaveKeyWithValue("platform", "")) + }) + + It("passes search platform aliases and limits to the App Store", func() { + var input appstore.SearchInput + + store.search = func(in appstore.SearchInput) (appstore.SearchOutput, error) { + input = in + + return appstore.SearchOutput{}, nil + } + Expect(call("search_apps", map[string]any{"term": "example", "platform": "tvos", "limit": 7}).IsError).To(BeFalse()) + Expect(input.Platform).To(Equal(appstore.PlatformAppleTV)) + Expect(input.Limit).To(Equal(int64(7))) + }) + + DescribeTable("rejects invalid inputs before accessing the account", func(name string, args map[string]any) { + accountCalls := 0 + store.accountInfo = func() (appstore.AccountInfoOutput, error) { + accountCalls++ + + return appstore.AccountInfoOutput{}, errors.New("unexpected account access") + } + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + Expect(err != nil || (result != nil && result.IsError)).To(BeTrue()) + Expect(accountCalls).To(BeZero()) + }, + Entry("missing search term", "search_apps", map[string]any{}), + Entry("blank search term", "search_apps", map[string]any{"term": " "}), + Entry("invalid search limit", "search_apps", map[string]any{"term": "app", "limit": 0}), + Entry("wrong argument type", "search_apps", map[string]any{"term": 123}), + Entry("credentials are not arguments", "search_apps", map[string]any{"term": "app", "password": "secret"}), + Entry("invalid platform", "search_apps", map[string]any{"term": "app", "platform": "unknown"}), + Entry("missing download app", "download_app", map[string]any{}), + Entry("negative app ID", "purchase_app", map[string]any{"app_id": -1}), + Entry("missing versions app", "list_app_versions", map[string]any{}), + Entry("invalid page", "list_purchases", map[string]any{"page": 0}), + Entry("invalid page size", "list_purchases", map[string]any{"max_results": 101}), + ) + + It("returns actionable account errors and keeps serving requests", func() { + store.accountInfo = func() (appstore.AccountInfoOutput, error) { + return appstore.AccountInfoOutput{}, errors.New("account not found") + } + result := call("list_purchases", map[string]any{}) + Expect(result.IsError).To(BeTrue()) + Expect(result.Content[0].(*mcp.TextContent).Text).To(ContainSubstring("ipatool auth login")) + _, err := session.ListTools(ctx, nil) + Expect(err).NotTo(HaveOccurred()) + }) + + DescribeTable("lists purchases with pagination", func(args map[string]any, page, limit int, platform appstore.Platform) { + var input appstore.OwnedAppsInput + + store.ownedApps = func(in appstore.OwnedAppsInput) (appstore.OwnedAppsOutput, error) { + input = in + + return appstore.OwnedAppsOutput{Count: 1, TotalCount: 11, Page: in.Page, Results: []appstore.App{app}}, nil + } + result := call("list_purchases", args) + Expect(result.IsError).To(BeFalse()) + Expect(input).To(Equal(appstore.OwnedAppsInput{Account: account, Page: page, Limit: limit, Platform: platform})) + Expect(result.StructuredContent).To(HaveKeyWithValue("totalCount", float64(11))) + Expect(result.StructuredContent).To(HaveKeyWithValue("page", float64(page))) + Expect(result.StructuredContent).To(HaveKeyWithValue("max_results", float64(limit))) + Expect(result.StructuredContent).To(HaveKeyWithValue("platform", string(platform))) + }, + Entry("defaults", map[string]any{}, 1, 10, appstore.Platform("")), + Entry("explicit values", map[string]any{"page": 2, "max_results": 5, "platform": "macos"}, 2, 5, appstore.PlatformMacOS), + ) + + It("resolves bundle identifiers and lists versions", func() { + var lookup appstore.LookupInput + var input appstore.ListVersionsInput + + store.lookup = func(in appstore.LookupInput) (appstore.LookupOutput, error) { + lookup = in + + return appstore.LookupOutput{App: app}, nil + } + store.listVersions = func(in appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) { + input = in + + return appstore.ListVersionsOutput{ExternalVersionIdentifiers: []string{"100", "200"}, LatestExternalVersionID: "200"}, nil + } + result := call("list_app_versions", map[string]any{"app_id": 999, "bundle_identifier": app.BundleID, "platform": "ipados"}) + Expect(result.IsError).To(BeFalse()) + Expect(lookup).To(Equal(appstore.LookupInput{Account: account, BundleID: app.BundleID, Platform: appstore.PlatformIPad})) + Expect(input.App).To(Equal(app)) + Expect(input.Context).NotTo(BeNil()) + Expect(result.StructuredContent).To(HaveKeyWithValue("externalVersionIdentifiers", []any{"100", "200"})) + Expect(result.StructuredContent).To(HaveKeyWithValue("app_id", float64(app.ID))) + Expect(result.StructuredContent).To(HaveKeyWithValue("bundle_identifier", app.BundleID)) + Expect(result.StructuredContent).To(HaveKeyWithValue("platform", "ipad")) + }) + + DescribeTable("purchases a free license", func(purchaseErr error, alreadyOwned bool) { + var input appstore.PurchaseInput + + store.purchase = func(in appstore.PurchaseInput) error { + input = in + + return purchaseErr + } + result := call("purchase_app", map[string]any{"app_id": 123, "platform": "visionos"}) + Expect(result.IsError).To(BeFalse()) + Expect(input).To(Equal(appstore.PurchaseInput{Account: account, App: appstore.App{ID: 123}, Platform: appstore.PlatformVisionOS})) + Expect(result.StructuredContent).To(HaveKeyWithValue("alreadyOwned", alreadyOwned)) + }, + Entry("new license", nil, false), + Entry("existing license", appstore.ErrLicenseAlreadyExists, true), + ) + + It("downloads a selected version and finalizes the package without a progress bar", func() { + var input appstore.DownloadInput + var sinfInput appstore.ReplicateSinfInput + + store.download = func(in appstore.DownloadInput) (appstore.DownloadOutput, error) { + input = in + + return appstore.DownloadOutput{DestinationPath: "/tmp/example.ipa"}, nil + } + store.replicateSinf = func(in appstore.ReplicateSinfInput) error { + sinfInput = in + + return nil + } + result := call("download_app", map[string]any{"app_id": 123, "output": "/tmp/example.ipa", "external_version_id": "200", "platform": "ios"}) + Expect(result.IsError).To(BeFalse()) + Expect(input.App.ID).To(Equal(int64(123))) + Expect(input.OutputPath).To(Equal("/tmp/example.ipa")) + Expect(input.ExternalVersionID).To(Equal("200")) + Expect(input.Platform).To(Equal(appstore.PlatformIPhone)) + Expect(input.Context).NotTo(BeNil()) + Expect(input.Progress).To(BeNil()) + Expect(sinfInput.PackagePath).To(Equal(input.OutputPath)) + Expect(result.StructuredContent).To(HaveKeyWithValue("output", input.OutputPath)) + Expect(result.StructuredContent).To(HaveKeyWithValue("external_version_id", "200")) + Expect(result.StructuredContent).To(HaveKeyWithValue("purchase", false)) + }) + + It("downloads delisted macOS apps by ID without attempting IPA finalization", func() { + var input appstore.DownloadInput + + store.lookup = func(appstore.LookupInput) (appstore.LookupOutput, error) { + return appstore.LookupOutput{}, appstore.ErrAppNotFound + } + store.download = func(in appstore.DownloadInput) (appstore.DownloadOutput, error) { + input = in + + return appstore.DownloadOutput{DestinationPath: "/tmp/example.pkg"}, nil + } + result := call("download_app", map[string]any{"app_id": 123, "bundle_identifier": app.BundleID, "platform": "macos"}) + Expect(result.IsError).To(BeFalse()) + Expect(input.App.ID).To(Equal(int64(123))) + }) + + DescribeTable("only acquires a license during download when requested", func(purchase bool) { + downloads, purchases := 0, 0 + store.download = func(appstore.DownloadInput) (appstore.DownloadOutput, error) { + downloads++ + if downloads == 1 { + return appstore.DownloadOutput{}, appstore.ErrLicenseRequired + } + + return appstore.DownloadOutput{DestinationPath: "/tmp/example.pkg"}, nil + } + store.purchase = func(appstore.PurchaseInput) error { + purchases++ + + return nil + } + result := call("download_app", map[string]any{"app_id": 123, "platform": "macos", "purchase": purchase}) + Expect(result.IsError).To(Equal(!purchase)) + if purchase { + Expect(purchases).To(Equal(1)) + Expect(downloads).To(Equal(2)) + Expect(result.StructuredContent).To(HaveKeyWithValue("purchased", true)) + } else { + Expect(purchases).To(BeZero()) + Expect(downloads).To(Equal(1)) + } + }, Entry("explicit opt in", true), Entry("no opt in", false)) + + It("refreshes an expired session using saved credentials", func() { + var login appstore.LoginInput + var accounts []appstore.Account + + refreshed := appstore.Account{PasswordToken: "refreshed-private-token"} + store.purchase = func(in appstore.PurchaseInput) error { + accounts = append(accounts, in.Account) + if len(accounts) == 1 { + return appstore.ErrPasswordTokenExpired + } + + return nil + } + store.login = func(in appstore.LoginInput) (appstore.LoginOutput, error) { + login = in + + return appstore.LoginOutput{Account: refreshed}, nil + } + Expect(call("purchase_app", map[string]any{"app_id": 123}).IsError).To(BeFalse()) + Expect(login).To(Equal(appstore.LoginInput{Email: account.Email, Password: account.Password})) + Expect(accounts).To(Equal([]appstore.Account{account, refreshed})) + }) + + It("reports two-factor requirements as tool errors without requesting credentials", func() { + store.purchase = func(appstore.PurchaseInput) error { + return appstore.ErrPasswordTokenExpired + } + store.login = func(appstore.LoginInput) (appstore.LoginOutput, error) { + return appstore.LoginOutput{}, appstore.ErrAuthCodeRequired + } + result := call("purchase_app", map[string]any{"app_id": 123}) + Expect(result.IsError).To(BeTrue()) + Expect(result.Content[0].(*mcp.TextContent).Text).To(ContainSubstring("ipatool auth login")) + }) + + It("does not expose App Store error metadata", func() { + store.search = func(appstore.SearchInput) (appstore.SearchOutput, error) { + return appstore.SearchOutput{}, appstore.NewErrorWithMetadata(errors.New("request failed"), account) + } + result := call("search_apps", map[string]any{"term": "example"}) + Expect(result.IsError).To(BeTrue()) + data, err := json.Marshal(result) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("request failed")) + Expect(string(data)).NotTo(ContainSubstring("private-")) + }) + + It("reports package finalization failures as tool errors", func() { + store.download = func(appstore.DownloadInput) (appstore.DownloadOutput, error) { + return appstore.DownloadOutput{DestinationPath: "/tmp/example.ipa"}, nil + } + store.replicateSinf = func(appstore.ReplicateSinfInput) error { + return errors.New("finalization failed") + } + result := call("download_app", map[string]any{"app_id": 123}) + Expect(result.IsError).To(BeTrue()) + Expect(result.Content[0].(*mcp.TextContent).Text).To(ContainSubstring("finalization failed")) + }) + + DescribeTable("returns every parameter and app field even when metadata is unavailable", func(name string, args map[string]any, expectedKeys []string) { + partial := appstore.App{ID: 123} + store.search = func(appstore.SearchInput) (appstore.SearchOutput, error) { + return appstore.SearchOutput{Count: 1, Results: []appstore.App{partial}}, nil + } + store.ownedApps = func(appstore.OwnedAppsInput) (appstore.OwnedAppsOutput, error) { + return appstore.OwnedAppsOutput{Count: 1, TotalCount: 1, Page: 1, Results: []appstore.App{partial}}, nil + } + store.listVersions = func(appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) { + return appstore.ListVersionsOutput{ExternalVersionIdentifiers: []string{"200"}}, nil + } + store.purchase = func(appstore.PurchaseInput) error { return nil } + store.download = func(appstore.DownloadInput) (appstore.DownloadOutput, error) { + return appstore.DownloadOutput{DestinationPath: "/tmp/example.ipa"}, nil + } + store.replicateSinf = func(appstore.ReplicateSinfInput) error { return nil } + result := call(name, args) + Expect(result.IsError).To(BeFalse()) + output := result.StructuredContent.(map[string]any) + for _, key := range expectedKeys { + Expect(output).To(HaveKey(key)) + } + + metadata := output["app"] + if metadata == nil { + metadata = output["apps"].([]any)[0] + } + Expect(metadata).To(Equal(map[string]any{ + "trackId": float64(123), "bundleId": "", "trackName": "", "version": "", + "price": float64(0), "purchaseDate": nil, "platforms": []any{}, + })) + textJSON, err := json.Marshal(result.StructuredContent) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Content[0].(*mcp.TextContent).Text).To(MatchJSON(string(textJSON))) + }, + Entry("search", "search_apps", map[string]any{"term": "example"}, []string{"term", "limit", "platform", "count", "apps"}), + Entry("purchases", "list_purchases", map[string]any{}, []string{"page", "max_results", "platform", "count", "totalCount", "apps"}), + Entry("versions", "list_app_versions", map[string]any{"app_id": 123}, []string{"app_id", "bundle_identifier", "platform", "bundleID", "app", "externalVersionIdentifiers", "latestExternalVersionID", "success"}), + Entry("purchase", "purchase_app", map[string]any{"app_id": 123}, []string{"app_id", "bundle_identifier", "platform", "app", "alreadyOwned", "success"}), + Entry("download", "download_app", map[string]any{"app_id": 123}, []string{"app_id", "bundle_identifier", "platform", "app", "output", "external_version_id", "purchase", "purchased", "success"}), + ) + + DescribeTable("fills missing bundle identifiers in app lists without losing purchase metadata", func(name string, args map[string]any) { + purchasedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) + partial := appstore.App{ID: 123, Name: "Original name", PurchaseDate: purchasedAt, Platforms: []appstore.Platform{appstore.PlatformMacOS}} + var lookup appstore.LookupInput + + store.lookup = func(in appstore.LookupInput) (appstore.LookupOutput, error) { + lookup = in + + return appstore.LookupOutput{App: appstore.App{ID: 123, BundleID: app.BundleID, Name: "Catalog name", Version: "2.0", Price: 4.99}}, nil + } + store.search = func(appstore.SearchInput) (appstore.SearchOutput, error) { + return appstore.SearchOutput{Count: 1, Results: []appstore.App{partial}}, nil + } + store.ownedApps = func(appstore.OwnedAppsInput) (appstore.OwnedAppsOutput, error) { + return appstore.OwnedAppsOutput{Count: 1, TotalCount: 1, Page: 1, Results: []appstore.App{partial}}, nil + } + result := call(name, args) + Expect(result.IsError).To(BeFalse()) + Expect(lookup).To(Equal(appstore.LookupInput{Account: account, AppID: 123, Platform: appstore.PlatformMacOS})) + metadata := result.StructuredContent.(map[string]any)["apps"].([]any)[0] + Expect(metadata).To(Equal(map[string]any{ + "trackId": float64(123), "bundleId": app.BundleID, "trackName": "Original name", "version": "2.0", + "price": 4.99, "purchaseDate": purchasedAt.Format(time.RFC3339), "platforms": []any{"macos"}, + })) + }, + Entry("search", "search_apps", map[string]any{"term": "example"}), + Entry("purchases", "list_purchases", map[string]any{}), + ) + + DescribeTable("resolves bundle identifiers for numeric app IDs", func(name string) { + var lookup appstore.LookupInput + + store.lookup = func(in appstore.LookupInput) (appstore.LookupOutput, error) { + lookup = in + + return appstore.LookupOutput{App: app}, nil + } + store.purchase = func(appstore.PurchaseInput) error { return nil } + store.listVersions = func(appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) { + return appstore.ListVersionsOutput{}, nil + } + store.download = func(appstore.DownloadInput) (appstore.DownloadOutput, error) { + return appstore.DownloadOutput{DestinationPath: "/tmp/example.ipa"}, nil + } + store.replicateSinf = func(appstore.ReplicateSinfInput) error { return nil } + result := call(name, map[string]any{"app_id": 123}) + Expect(result.IsError).To(BeFalse()) + Expect(lookup.AppID).To(Equal(int64(123))) + Expect(result.StructuredContent).To(HaveKeyWithValue("app_id", float64(123))) + Expect(result.StructuredContent).To(HaveKeyWithValue("bundle_identifier", app.BundleID)) + Expect(result.StructuredContent.(map[string]any)["app"]).To(HaveKeyWithValue("bundleId", app.BundleID)) + if name == "list_app_versions" { + Expect(result.StructuredContent).To(HaveKeyWithValue("bundleID", app.BundleID)) + Expect(result.StructuredContent).To(HaveKeyWithValue("externalVersionIdentifiers", []any{})) + } + }, Entry("purchase", "purchase_app"), Entry("download", "download_app"), Entry("versions", "list_app_versions")) +}) + +type fakeMCPAppStore struct { + appstore.AppStore + accountInfo func() (appstore.AccountInfoOutput, error) + search func(appstore.SearchInput) (appstore.SearchOutput, error) + lookup func(appstore.LookupInput) (appstore.LookupOutput, error) + ownedApps func(appstore.OwnedAppsInput) (appstore.OwnedAppsOutput, error) + listVersions func(appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) + purchase func(appstore.PurchaseInput) error + download func(appstore.DownloadInput) (appstore.DownloadOutput, error) + replicateSinf func(appstore.ReplicateSinfInput) error + login func(appstore.LoginInput) (appstore.LoginOutput, error) +} + +func (s *fakeMCPAppStore) AccountInfo() (appstore.AccountInfoOutput, error) { + return s.accountInfo() +} + +func (s *fakeMCPAppStore) Search(input appstore.SearchInput) (appstore.SearchOutput, error) { + return s.search(input) +} + +func (s *fakeMCPAppStore) Lookup(input appstore.LookupInput) (appstore.LookupOutput, error) { + return s.lookup(input) +} + +func (s *fakeMCPAppStore) OwnedApps(input appstore.OwnedAppsInput) (appstore.OwnedAppsOutput, error) { + return s.ownedApps(input) +} + +func (s *fakeMCPAppStore) ListVersions(input appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) { + return s.listVersions(input) +} + +func (s *fakeMCPAppStore) Purchase(input appstore.PurchaseInput) error { + return s.purchase(input) +} + +func (s *fakeMCPAppStore) Download(input appstore.DownloadInput) (appstore.DownloadOutput, error) { + return s.download(input) +} + +func (s *fakeMCPAppStore) ReplicateSinf(input appstore.ReplicateSinfInput) error { + return s.replicateSinf(input) +} + +func (s *fakeMCPAppStore) Login(input appstore.LoginInput) (appstore.LoginOutput, error) { + return s.login(input) +} diff --git a/cmd/mcp_tools.go b/cmd/mcp_tools.go new file mode 100644 index 00000000..93c62242 --- /dev/null +++ b/cmd/mcp_tools.go @@ -0,0 +1,311 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/majd/ipatool/v2/pkg/appstore" +) + +type mcpSearchInput struct { + Term string `json:"term" jsonschema:"Search term"` + Limit *int64 `json:"limit,omitempty" jsonschema:"Maximum results; defaults to 5. visionOS supports up to 12."` + Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; defaults to iOS and iPadOS"` +} + +type mcpSearchOutput struct { + Term string `json:"term"` + Limit int64 `json:"limit"` + Platform appstore.Platform `json:"platform"` + Count int `json:"count"` + Apps []mcpApp `json:"apps"` +} + +type mcpAppInput struct { + AppID int64 `json:"app_id,omitempty" jsonschema:"Numeric app ID; required unless bundle_identifier is provided"` + BundleID string `json:"bundle_identifier,omitempty" jsonschema:"Bundle identifier; overrides app_id when found"` + Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; uses the CLI default when omitted"` +} + +type mcpDownloadInput struct { + mcpAppInput + Output string `json:"output,omitempty" jsonschema:"Destination file or directory; defaults to the server's working directory"` + ExternalVersionID string `json:"external_version_id,omitempty" jsonschema:"External version identifier; defaults to the latest version"` + Purchase bool `json:"purchase,omitempty" jsonschema:"Acquire a free license if needed; defaults to false"` +} + +type mcpDownloadOutput struct { + mcpAppParameters + App mcpApp `json:"app"` + Output string `json:"output"` + ExternalVersionID string `json:"external_version_id"` + Purchase bool `json:"purchase"` + Purchased bool `json:"purchased"` + Success bool `json:"success"` +} + +type mcpVersionsOutput struct { + mcpAppParameters + App mcpApp `json:"app"` + ExternalVersionIdentifiers []string `json:"externalVersionIdentifiers"` + LatestExternalVersionID string `json:"latestExternalVersionID"` + BundleID string `json:"bundleID"` + Success bool `json:"success"` +} + +type mcpPurchasesInput struct { + Page *int `json:"page,omitempty" jsonschema:"Page number starting at 1; defaults to 1"` + MaxResults *int `json:"max_results,omitempty" jsonschema:"Apps per page from 1 to 100; defaults to 10"` + Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; defaults to all platforms"` +} + +type mcpPurchasesOutput struct { + Count int `json:"count"` + TotalCount int `json:"totalCount"` + Page int `json:"page"` + MaxResults int `json:"max_results"` + Platform appstore.Platform `json:"platform"` + Apps []mcpApp `json:"apps"` +} + +type mcpPurchaseOutput struct { + mcpAppParameters + App mcpApp `json:"app"` + AlreadyOwned bool `json:"alreadyOwned"` + Success bool `json:"success"` +} + +// withMCPAccount refreshes expired tokens using the saved credentials, as the CLI does. +// Authentication and account details are never included in tool results. +func withMCPAccount[Out any](ctx context.Context, store appstore.AppStore, operation func(appstore.Account) (Out, error)) (Out, error) { + var zero Out + + info, err := store.AccountInfo() + if err != nil { + return zero, fmt.Errorf("account unavailable; run 'ipatool auth login' before using MCP: %w", err) + } + + output, err := operation(info.Account) + if !errors.Is(err, appstore.ErrPasswordTokenExpired) { + return output, err + } + + if err := ctx.Err(); err != nil { + return zero, fmt.Errorf("tool call canceled: %w", err) + } + + login, err := store.Login(appstore.LoginInput{Email: info.Account.Email, Password: info.Account.Password}) + if err != nil { + return zero, fmt.Errorf("session refresh failed; run 'ipatool auth login' outside MCP: %w", err) + } + + output, err = operation(login.Account) + if err != nil { + return zero, fmt.Errorf("operation failed after session refresh: %w", err) + } + + return output, nil +} + +//nolint:wrapcheck +func (t *mcpTools) search(ctx context.Context, input mcpSearchInput) (mcpSearchOutput, error) { + if strings.TrimSpace(input.Term) == "" { + return mcpSearchOutput{}, errors.New("search term must not be empty") + } + + limit := int64(5) + if input.Limit != nil { + limit = *input.Limit + } + + if limit < 1 { + return mcpSearchOutput{}, errors.New("limit must be greater than 0") + } + + platform, err := appstore.ParsePlatform(input.Platform) + if err != nil { + return mcpSearchOutput{}, err + } + + return withMCPAccount(ctx, t.store, func(acc appstore.Account) (mcpSearchOutput, error) { + out, err := t.store.Search(appstore.SearchInput{Account: acc, Term: input.Term, Limit: limit, Platform: platform}) + if err != nil { + return mcpSearchOutput{}, err + } + + return mcpSearchOutput{ + Term: input.Term, Limit: limit, Platform: platform, + Count: out.Count, Apps: t.appResults(acc, out.Results, platform), + }, nil + }) +} + +//nolint:wrapcheck +func (input mcpAppInput) validate() (appstore.Platform, error) { + if input.AppID < 0 || (input.AppID == 0 && strings.TrimSpace(input.BundleID) == "") { + return "", errors.New("a positive app ID or a bundle identifier must be specified") + } + + return appstore.ParsePlatform(input.Platform) +} + +//nolint:wrapcheck +func (t *mcpTools) resolveApp(acc appstore.Account, input mcpAppInput, platform appstore.Platform, allowDelisted bool) (appstore.App, error) { + app := appstore.App{ID: input.AppID, BundleID: input.BundleID} + if input.BundleID == "" { + return t.completeApp(acc, app, platform), nil + } + + out, err := t.store.Lookup(appstore.LookupInput{Account: acc, BundleID: input.BundleID, Platform: platform}) + if allowDelisted && input.AppID > 0 && errors.Is(err, appstore.ErrAppNotFound) { + return app, nil + } + + if err != nil { + return appstore.App{}, err + } + + // Preserve a known bundle identifier even if the catalog omits it. + if out.App.BundleID == "" { + out.App.BundleID = input.BundleID + } + + return out.App, nil +} + +//nolint:wrapcheck +func (t *mcpTools) download(ctx context.Context, input mcpDownloadInput) (mcpDownloadOutput, error) { + platform, err := input.validate() + if err != nil { + return mcpDownloadOutput{}, err + } + + purchased := false + + return withMCPAccount(ctx, t.store, func(acc appstore.Account) (mcpDownloadOutput, error) { + app, err := t.resolveApp(acc, input.mcpAppInput, platform, true) + if err != nil { + return mcpDownloadOutput{}, err + } + + downloadInput := appstore.DownloadInput{ + Context: ctx, Account: acc, App: app, OutputPath: input.Output, + ExternalVersionID: input.ExternalVersionID, Platform: platform, + } + + out, err := t.store.Download(downloadInput) + if errors.Is(err, appstore.ErrLicenseRequired) && input.Purchase && !purchased { + err = t.store.Purchase(appstore.PurchaseInput{Account: acc, App: app, Platform: platform}) + if err != nil && !errors.Is(err, appstore.ErrLicenseAlreadyExists) { + return mcpDownloadOutput{}, err + } + + purchased = true + out, err = t.store.Download(downloadInput) + } + + if err != nil { + return mcpDownloadOutput{}, err + } + + if err := replicateDownloadSinf(t.store, platform, out); err != nil { + return mcpDownloadOutput{}, err + } + + return mcpDownloadOutput{ + mcpAppParameters: appParameters(app, platform), App: newMCPApp(app), + Output: out.DestinationPath, ExternalVersionID: input.ExternalVersionID, + Purchase: input.Purchase, Purchased: purchased, Success: true, + }, nil + }) +} + +//nolint:wrapcheck +func (t *mcpTools) listVersions(ctx context.Context, input mcpAppInput) (mcpVersionsOutput, error) { + platform, err := input.validate() + if err != nil { + return mcpVersionsOutput{}, err + } + + return withMCPAccount(ctx, t.store, func(acc appstore.Account) (mcpVersionsOutput, error) { + app, err := t.resolveApp(acc, input, platform, false) + if err != nil { + return mcpVersionsOutput{}, err + } + + out, err := t.store.ListVersions(appstore.ListVersionsInput{Context: ctx, Account: acc, App: app, Platform: platform}) + if err != nil { + return mcpVersionsOutput{}, err + } + + return mcpVersionsOutput{ + mcpAppParameters: appParameters(app, platform), App: newMCPApp(app), + ExternalVersionIdentifiers: append([]string{}, out.ExternalVersionIdentifiers...), + LatestExternalVersionID: out.LatestExternalVersionID, BundleID: app.BundleID, Success: true, + }, nil + }) +} + +//nolint:wrapcheck +func (t *mcpTools) listPurchases(ctx context.Context, input mcpPurchasesInput) (mcpPurchasesOutput, error) { + page, limit := 1, appstore.DefaultOwnedAppsLimit + if input.Page != nil { + page = *input.Page + } + + if input.MaxResults != nil { + limit = *input.MaxResults + } + + if page < 1 { + return mcpPurchasesOutput{}, errors.New("page must be greater than 0") + } + + if limit < 1 || limit > appstore.MaxOwnedAppsLimit { + return mcpPurchasesOutput{}, fmt.Errorf("max results must be between 1 and %d", appstore.MaxOwnedAppsLimit) + } + + platform, err := appstore.ParsePlatform(input.Platform) + if err != nil { + return mcpPurchasesOutput{}, err + } + + return withMCPAccount(ctx, t.store, func(acc appstore.Account) (mcpPurchasesOutput, error) { + out, err := t.store.OwnedApps(appstore.OwnedAppsInput{Account: acc, Page: page, Limit: limit, Platform: platform}) + if err != nil { + return mcpPurchasesOutput{}, err + } + + return mcpPurchasesOutput{ + Count: out.Count, TotalCount: out.TotalCount, Page: out.Page, MaxResults: limit, Platform: platform, + Apps: t.appResults(acc, out.Results, platform), + }, nil + }) +} + +//nolint:wrapcheck +func (t *mcpTools) purchase(ctx context.Context, input mcpAppInput) (mcpPurchaseOutput, error) { + platform, err := input.validate() + if err != nil { + return mcpPurchaseOutput{}, err + } + + return withMCPAccount(ctx, t.store, func(acc appstore.Account) (mcpPurchaseOutput, error) { + app, err := t.resolveApp(acc, input, platform, false) + if err != nil { + return mcpPurchaseOutput{}, err + } + + err = t.store.Purchase(appstore.PurchaseInput{Account: acc, App: app, Platform: platform}) + if err != nil && !errors.Is(err, appstore.ErrLicenseAlreadyExists) { + return mcpPurchaseOutput{}, err + } + + return mcpPurchaseOutput{ + mcpAppParameters: appParameters(app, platform), App: newMCPApp(app), + AlreadyOwned: errors.Is(err, appstore.ErrLicenseAlreadyExists), Success: true, + }, nil + }) +} diff --git a/cmd/root.go b/cmd/root.go index 7eb75d51..b2aa044e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,6 +52,7 @@ func rootCmd() *cobra.Command { cmd.AddCommand(searchCmd()) cmd.AddCommand(ListVersionsCmd()) cmd.AddCommand(getVersionMetadataCmd()) + cmd.AddCommand(mcpCmd()) return cmd } diff --git a/go.mod b/go.mod index 29862f44..63955e14 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/ebitengine/purego v0.10.2 github.com/juju/persistent-cookiejar v1.0.0 github.com/klauspost/compress v1.18.6 + github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/onsi/ginkgo/v2 v2.5.0 github.com/onsi/gomega v1.24.0 github.com/rs/zerolog v1.28.0 @@ -41,6 +42,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect @@ -54,6 +56,8 @@ require ( github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stangelandcl/ppmd v0.1.0 // indirect @@ -62,6 +66,7 @@ require ( github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect github.com/ulikunitz/xz v0.5.15 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect @@ -69,8 +74,10 @@ require ( golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect diff --git a/go.sum b/go.sum index 5fc29103..3e3c7d73 100644 --- a/go.sum +++ b/go.sum @@ -50,11 +50,15 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -87,6 +91,8 @@ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWV github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/noamcohen97/touchid-go v0.3.0 h1:fcXxVCizysD7KHRR6hrURt3nyNIs5JBGSbOIidD/3wo= @@ -113,6 +119,10 @@ github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6us github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -148,6 +158,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -164,6 +176,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -177,6 +191,8 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index d5e2d036..b107915e 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -16,7 +16,7 @@ type AppStore interface { AccountInfo() (AccountInfoOutput, error) // Revoke revokes the active credentials. Revoke() error - // Lookup looks apps up based on the specified bundle identifier. + // Lookup looks apps up by bundle identifier or numeric app ID. Lookup(input LookupInput) (LookupOutput, error) // Search searches the App Store for apps matching the specified term. Search(input SearchInput) (SearchOutput, error) diff --git a/pkg/appstore/appstore_lookup.go b/pkg/appstore/appstore_lookup.go index 3645f15b..e53c1447 100644 --- a/pkg/appstore/appstore_lookup.go +++ b/pkg/appstore/appstore_lookup.go @@ -5,6 +5,7 @@ import ( "fmt" gohttp "net/http" "net/url" + "strconv" "github.com/majd/ipatool/v2/pkg/http" ) @@ -13,6 +14,7 @@ var ErrAppNotFound = errors.New("app not found") type LookupInput struct { Account Account + AppID int64 BundleID string Platform Platform } @@ -27,7 +29,13 @@ func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { return LookupOutput{}, fmt.Errorf("failed to resolve the country code: %w", err) } - request, err := t.lookupRequest(input.BundleID, countryCode, input.Platform) + var request http.Request + if input.BundleID == "" && input.AppID > 0 { + request, err = t.lookupIDsRequest([]string{strconv.FormatInt(input.AppID, 10)}, countryCode, input.Platform) + } else { + request, err = t.lookupRequest(input.BundleID, countryCode, input.Platform) + } + if err != nil { return LookupOutput{}, fmt.Errorf("failed to create lookup request: %w", err) } diff --git a/pkg/appstore/appstore_lookup_test.go b/pkg/appstore/appstore_lookup_test.go index 765921a5..574b7c82 100644 --- a/pkg/appstore/appstore_lookup_test.go +++ b/pkg/appstore/appstore_lookup_test.go @@ -29,6 +29,33 @@ var _ = Describe("AppStore (Lookup)", func() { ctrl.Finish() }) + DescribeTable("looks up an app by ID or bundle identifier", func(appID int64, bundleID, key, value string) { + mockClient.EXPECT().Send(gomock.Any()).DoAndReturn(func(req http.Request) (http.Result[searchResult], error) { + parsed, err := url.Parse(req.URL) + Expect(err).NotTo(HaveOccurred()) + Expect(parsed.Query().Get(key)).To(Equal(value)) + Expect(parsed.Query().Get("entity")).To(Equal("macSoftware")) + Expect(parsed.Query().Get("country")).To(Equal("US")) + if key == "bundleId" { + Expect(parsed.Query().Has("id")).To(BeFalse()) + } else { + Expect(parsed.Query().Has("bundleId")).To(BeFalse()) + } + + return http.Result[searchResult]{ + StatusCode: 200, Data: searchResult{Results: []App{{ID: 123, BundleID: "com.example.app"}}}, + }, nil + }) + output, err := as.Lookup(LookupInput{ + Account: Account{StoreFront: "143441"}, AppID: appID, BundleID: bundleID, Platform: PlatformMacOS, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(output.App.BundleID).To(Equal("com.example.app")) + }, + Entry("numeric ID", int64(123), "", "id", "123"), + Entry("bundle identifier overrides ID", int64(999), "com.example.app", "bundleId", "com.example.app"), + ) + When("request is successful", func() { When("does not find app", func() { BeforeEach(func() {