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
4 changes: 4 additions & 0 deletions cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
86 changes: 86 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -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
})
}
93 changes: 93 additions & 0 deletions cmd/mcp_app.go
Original file line number Diff line number Diff line change
@@ -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}
}
32 changes: 32 additions & 0 deletions cmd/mcp_app_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
Loading
Loading