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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func newMCPServer(store appstore.AppStore) *mcp.Server {
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, "get_version_metadata", "Retrieve the display version and release date for a specific external version identifier of an app.", true, tools.getVersionMetadata)
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)

Expand Down
140 changes: 129 additions & 11 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ var _ = Describe("MCP server", func() {
Expect(command.Args(command, []string{"unexpected"})).NotTo(Succeed())
})

It("advertises exactly five tools and their schemas without auth functions", func() {
It("advertises exactly six 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))
Expand All @@ -68,7 +68,7 @@ var _ = Describe("MCP server", func() {
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"))
Expect(names).To(ConsistOf("search_apps", "download_app", "list_app_versions", "get_version_metadata", "list_purchases", "purchase_app"))
})

It("searches with CLI defaults and returns structured app data without credentials", func() {
Expand Down Expand Up @@ -125,6 +125,12 @@ var _ = Describe("MCP server", func() {
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("missing metadata app", "get_version_metadata", map[string]any{"external_version_id": "200"}),
Entry("missing metadata version", "get_version_metadata", map[string]any{"app_id": 123}),
Entry("empty metadata version", "get_version_metadata", map[string]any{"app_id": 123, "external_version_id": ""}),
Entry("blank metadata version", "get_version_metadata", map[string]any{"app_id": 123, "external_version_id": " "}),
Entry("invalid metadata version type", "get_version_metadata", map[string]any{"app_id": 123, "external_version_id": 200}),
Entry("invalid metadata platform", "get_version_metadata", map[string]any{"app_id": 123, "external_version_id": "200", "platform": "unknown"}),
Entry("invalid page", "list_purchases", map[string]any{"page": 0}),
Entry("invalid page size", "list_purchases", map[string]any{"max_results": 101}),
)
Expand Down Expand Up @@ -185,6 +191,109 @@ var _ = Describe("MCP server", func() {
Expect(result.StructuredContent).To(HaveKeyWithValue("platform", "ipad"))
})

It("resolves a bundle identifier and returns metadata for the requested version", func() {
var lookup appstore.LookupInput
var input appstore.GetVersionMetadataInput

app.Version = "2.0"
releasedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
store.lookup = func(in appstore.LookupInput) (appstore.LookupOutput, error) {
lookup = in

return appstore.LookupOutput{App: app}, nil
}
store.getVersionMetadata = func(in appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) {
input = in

return appstore.GetVersionMetadataOutput{DisplayVersion: "1.2.3", ReleaseDate: releasedAt}, nil
}
result := call("get_version_metadata", map[string]any{"app_id": 999, "bundle_identifier": app.BundleID, "external_version_id": "200", "platform": "ipados"})
Expect(result.IsError).To(BeFalse())
Expect(lookup).To(Equal(appstore.LookupInput{Account: account, BundleID: app.BundleID, Platform: appstore.PlatformIPad}))
Expect(input.Context).NotTo(BeNil())
Expect(input.Account).To(Equal(account))
Expect(input.App).To(Equal(app))
Expect(input.VersionID).To(Equal("200"))
Expect(input.Platform).To(Equal(appstore.PlatformIPad))
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"))
Expect(result.StructuredContent).To(HaveKeyWithValue("external_version_id", "200"))
Expect(result.StructuredContent).To(HaveKeyWithValue("displayVersion", "1.2.3"))
Expect(result.StructuredContent).To(HaveKeyWithValue("releaseDate", releasedAt.Format(time.RFC3339)))
Expect(result.StructuredContent).To(HaveKeyWithValue("success", true))
Expect(result.StructuredContent.(map[string]any)["app"]).To(HaveKeyWithValue("version", "2.0"))
data, err := json.Marshal(result.StructuredContent)
Expect(err).NotTo(HaveOccurred())
Expect(string(data)).NotTo(ContainSubstring("private-"))
Expect(result.Content[0].(*mcp.TextContent).Text).To(MatchJSON(string(data)))
})

DescribeTable("retrieves version metadata by app ID when catalog metadata is unavailable", func(platform string, expected appstore.Platform) {
var input appstore.GetVersionMetadataInput

store.getVersionMetadata = func(in appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) {
input = in

return appstore.GetVersionMetadataOutput{DisplayVersion: "1.2.3"}, nil
}
result := call("get_version_metadata", map[string]any{"app_id": 123, "external_version_id": "200", "platform": platform})
Expect(result.IsError).To(BeFalse())
Expect(input.App).To(Equal(appstore.App{ID: 123}))
Expect(input.VersionID).To(Equal("200"))
Expect(input.Platform).To(Equal(expected))
},
Entry("CLI default", "", appstore.Platform("")),
Entry("iOS alias", "ios", appstore.PlatformIPhone),
Entry("macOS", "macos", appstore.PlatformMacOS),
)

It("returns bundle lookup failures as metadata tool errors", func() {
result := call("get_version_metadata", map[string]any{"bundle_identifier": app.BundleID, "external_version_id": "200"})
Expect(result.IsError).To(BeTrue())
Expect(result.Content[0].(*mcp.TextContent).Text).To(ContainSubstring(appstore.ErrAppNotFound.Error()))
})

DescribeTable("returns metadata failures as tool errors without acquiring a license", func(metadataErr error) {
store.getVersionMetadata = func(appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) {
return appstore.GetVersionMetadataOutput{}, appstore.NewErrorWithMetadata(metadataErr, account)
}
result := call("get_version_metadata", map[string]any{"app_id": 123, "external_version_id": "200"})
Expect(result.IsError).To(BeTrue())
Expect(result.Content[0].(*mcp.TextContent).Text).To(ContainSubstring(metadataErr.Error()))
data, err := json.Marshal(result)
Expect(err).NotTo(HaveOccurred())
Expect(string(data)).NotTo(ContainSubstring("private-"))
},
Entry("missing license", appstore.ErrLicenseRequired),
Entry("metadata unavailable", errors.New("metadata unavailable")),
)

It("refreshes an expired session when retrieving version metadata", func() {
var login appstore.LoginInput
var accounts []appstore.Account

refreshed := appstore.Account{PasswordToken: "refreshed-private-token"}
store.getVersionMetadata = func(in appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) {
accounts = append(accounts, in.Account)
if len(accounts) == 1 {
return appstore.GetVersionMetadataOutput{}, appstore.ErrPasswordTokenExpired
}

return appstore.GetVersionMetadataOutput{DisplayVersion: "1.2.3"}, nil
}
store.login = func(in appstore.LoginInput) (appstore.LoginOutput, error) {
login = in

return appstore.LoginOutput{Account: refreshed}, nil
}
result := call("get_version_metadata", map[string]any{"app_id": 123, "external_version_id": "200"})
Expect(result.IsError).To(BeFalse())
Expect(login).To(Equal(appstore.LoginInput{Email: account.Email, Password: account.Password}))
Expect(accounts).To(Equal([]appstore.Account{account, refreshed}))
Expect(result.StructuredContent).To(HaveKeyWithValue("displayVersion", "1.2.3"))
})

DescribeTable("purchases a free license", func(purchaseErr error, alreadyOwned bool) {
var input appstore.PurchaseInput

Expand Down Expand Up @@ -343,6 +452,9 @@ var _ = Describe("MCP server", func() {
store.listVersions = func(appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) {
return appstore.ListVersionsOutput{ExternalVersionIdentifiers: []string{"200"}}, nil
}
store.getVersionMetadata = func(appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) {
return appstore.GetVersionMetadataOutput{}, 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
Expand Down Expand Up @@ -370,6 +482,7 @@ var _ = Describe("MCP server", func() {
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("version metadata", "get_version_metadata", map[string]any{"app_id": 123, "external_version_id": "200"}, []string{"app_id", "bundle_identifier", "platform", "app", "external_version_id", "displayVersion", "releaseDate", "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"}),
)
Expand Down Expand Up @@ -434,15 +547,16 @@ var _ = Describe("MCP server", func() {

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)
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)
getVersionMetadata func(appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, 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) {
Expand All @@ -465,6 +579,10 @@ func (s *fakeMCPAppStore) ListVersions(input appstore.ListVersionsInput) (appsto
return s.listVersions(input)
}

func (s *fakeMCPAppStore) GetVersionMetadata(input appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) {
return s.getVersionMetadata(input)
}

func (s *fakeMCPAppStore) Purchase(input appstore.PurchaseInput) error {
return s.purchase(input)
}
Expand Down
47 changes: 47 additions & 0 deletions cmd/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"

"github.com/majd/ipatool/v2/pkg/appstore"
)
Expand Down Expand Up @@ -55,6 +56,20 @@ type mcpVersionsOutput struct {
Success bool `json:"success"`
}

type mcpVersionMetadataInput struct {
mcpAppInput
ExternalVersionID string `json:"external_version_id" jsonschema:"External version identifier of the target app (required)"`
}

type mcpVersionMetadataOutput struct {
mcpAppParameters
App mcpApp `json:"app"`
ExternalVersionID string `json:"external_version_id"`
DisplayVersion string `json:"displayVersion" jsonschema:"Display version of the requested external version identifier"`
ReleaseDate time.Time `json:"releaseDate"`
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"`
Expand Down Expand Up @@ -248,6 +263,38 @@ func (t *mcpTools) listVersions(ctx context.Context, input mcpAppInput) (mcpVers
})
}

//nolint:wrapcheck
func (t *mcpTools) getVersionMetadata(ctx context.Context, input mcpVersionMetadataInput) (mcpVersionMetadataOutput, error) {
platform, err := input.validate()
if err != nil {
return mcpVersionMetadataOutput{}, err
}

if strings.TrimSpace(input.ExternalVersionID) == "" {
return mcpVersionMetadataOutput{}, errors.New("external version identifier must not be empty")
}

return withMCPAccount(ctx, t.store, func(acc appstore.Account) (mcpVersionMetadataOutput, error) {
app, err := t.resolveApp(acc, input.mcpAppInput, platform, false)
if err != nil {
return mcpVersionMetadataOutput{}, err
}

out, err := t.store.GetVersionMetadata(appstore.GetVersionMetadataInput{
Context: ctx, Account: acc, App: app, VersionID: input.ExternalVersionID, Platform: platform,
})
if err != nil {
return mcpVersionMetadataOutput{}, err
}

return mcpVersionMetadataOutput{
mcpAppParameters: appParameters(app, platform), App: newMCPApp(app),
ExternalVersionID: input.ExternalVersionID, DisplayVersion: out.DisplayVersion,
ReleaseDate: out.ReleaseDate, Success: true,
}, nil
})
}

//nolint:wrapcheck
func (t *mcpTools) listPurchases(ctx context.Context, input mcpPurchasesInput) (mcpPurchasesOutput, error) {
page, limit := 1, appstore.DefaultOwnedAppsLimit
Expand Down
Loading