package server import ( "context" "encoding/json" "fmt" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/nacorid/naco-api/internal/x402" ) // registerTools registers all MCP tools with their x402 payment requirements. // The payable tools are defined in a table to avoid repeating Schema construction. func (s *Server) registerTools() { // Helper to build the x402 extra map from input/output BodyFields. schemaExtra := func(input, output map[string]any) map[string]any { return map[string]any{ "OutputSchema": Schema{ Input: InputSchema{ Type: "HTTP", Method: "POST", Discoverable: true, BodyFields: input, }, Output: output, }, } } analyticsInput := map[string]any{ "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, "startTime": Bodyfield{Type: "string", Description: "Start time in RFC3339 format (default: 30 days ago)", Required: false}, "endTime": Bodyfield{Type: "string", Description: "End time in RFC3339 format (default: now)", Required: false}, "limit": Bodyfield{Type: "integer", Description: "Maximum number of posts to analyze (default: 50, max: 200)", Required: false}, "platform": Bodyfield{Type: "string", Description: "Platform to filter (default: all)", Required: false}, } type payableDef struct { Name string Desc string Input any Output any Handler func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) Price string MaxTimeoutSeconds int // UsesTwitter marks tools that may read posts (or set up lookups) // through the Twitter API and return only transformative/aggregated // results, as permitted by the X API policy. With Twitter enabled these // tools charge upto-scheme: base price plus the realized Twitter API // costs. Tools that resell raw post/profile/feed data (getPost, // getLatestPosts, ...) never set this. UsesTwitter bool // TwitterPostCap is the post count used to compute the advertised upto // maximum (see x402.MaxTwitterCostMicro). It only bounds the largest // settlement the tool can demand; the actual charge is settled from the // response. TwitterPostCap int64 Extra map[string]any } payable := []payableDef{ { Name: "getPost", Desc: "Get a specific post from a specific actor", Input: GetPostRequest{}, Output: GetPostResponsePost{}, Handler: s.getPost, Price: "0.01", Extra: schemaExtra( map[string]any{ "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, "rkey": Bodyfield{Type: "string", Description: "Record key of the post", Required: true}, "collection": Bodyfield{Type: "string", Description: "Collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'", Required: false}, }, map[string]any{ "author": Bodyfield{Type: "string", Description: "Did of the author"}, "post": Bodyfield{Type: "object", Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"}, }, ), }, { Name: "getLatestPosts", Desc: "Get the latest n posts from a specific actor", Input: GetLatestPostsRequest{}, Output: GetLatestPostsResponse{}, Handler: s.getLatestPosts, Price: "0.10", Extra: schemaExtra( map[string]any{ "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, "n": Bodyfield{Type: "integer", Description: "Number of posts to retrieve, maximum 50", Required: true}, }, map[string]any{ "author": Bodyfield{Type: "string", Description: "Did of an actor"}, "posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"}, }, ), }, { Name: "getPostsInTime", Desc: "Get posts from a specific actor in a time range", Input: GetPostsInTimeRequest{}, Output: GetPostsInTimeResponse{}, Handler: s.getPostsInTime, Price: "0.10", Extra: schemaExtra( map[string]any{ "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, "start": Bodyfield{Type: "string", Description: "Start time in RFC3339 format", Required: true}, "end": Bodyfield{Type: "string", Description: "End time in RFC3339 format", Required: true}, "n": Bodyfield{Type: "integer", Description: "Maximum number of posts to retrieve, maximum 50", Required: true}, }, map[string]any{ "author": Bodyfield{Type: "string", Description: "Did of an actor"}, "posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"}, }, ), }, { // base price is $0.25 (x402.BaseProfileCostMicro). With Twitter // enabled the tool is advertised as upto at the maximum possible // cost - $0.25 base + $0.01 lookup + $0.005/post at the 100-post // cap = $0.76 (x402.MaxTwitterCostMicro(BaseProfileCostMicro, // MaxProfileCountedPosts)) - and the actual charge is settled from // the posts read via x402.NewStyleProfileCostResolver. Name: "getStyleProfile", Desc: "Get an actor's style Profile", Input: GetStyleProfileRequest{}, Output: GetStyleProfileResponse{}, Handler: s.getStyleProfile, Price: "0.25", MaxTimeoutSeconds: 600, UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts, Extra: schemaExtra( map[string]any{ "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, }, map[string]any{ "PersonaEmbedding": Bodyfield{Type: "array", Description: "Embedding vector representing the actor's persona"}, "TopKeywords": Bodyfield{Type: "array", Description: "Top words used by the actor"}, "StyleSummary": Bodyfield{Type: "string", Description: "Summary of the actor's writing style"}, "PostCount": Bodyfield{Type: "integer", Description: "Number of posts analyzed"}, }, ), }, { Name: "findSimilarUsers", Desc: "Find authors with a similar writing style to the given author, across all platforms", Input: FindSimilarUsersRequest{}, Output: FindSimilarUsersResponse{}, Handler: s.getSimilarProfiles, Price: "0.10", UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts, Extra: schemaExtra( map[string]any{ "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, }, map[string]any{ "matches": Bodyfield{Type: "array", Description: "Ranked list of similar profiles (author, platform, similarity, top keywords, style summary, post count)"}, }, ), }, { Name: "getActor", Desc: "Get an actor's public profile (bio, avatar, follower counts) on the platform inferred from the identifier", Input: GetActorRequest{}, Output: GetActorResponse{}, Handler: s.getActor, Price: "0.01", Extra: schemaExtra( map[string]any{ "author": Bodyfield{Type: "string", Description: "Did, handle, npub or @handle of an actor", Required: true}, }, map[string]any{ "actor": Bodyfield{Type: "object", Description: "Profile of the actor (platform, handle, displayName, avatarUrl, bio, followersCount, followingCount, postCount, url)"}, }, ), }, { Name: "searchActors", Desc: "Search for actors by handle/name/keyword across all platforms", Input: SearchActorsRequest{}, Output: SearchActorsResponse{}, Handler: s.searchActors, Price: "0.01", Extra: schemaExtra( map[string]any{ "query": Bodyfield{Type: "string", Description: "Search query (handle, name or keyword)", Required: true}, "limit": Bodyfield{Type: "integer", Description: "Maximum number of results (default 10, max 50)", Required: false}, }, map[string]any{ "results": Bodyfield{Type: "array", Description: "Ranked list of matching actor profiles across platforms"}, }, ), }, { Name: "getEngagementMetrics", Desc: "Get time-series engagement metrics (likes, reposts, replies, quotes) per post", Input: AnalyticsRequest{}, Output: EngagementMetricsResponse{}, Handler: s.getEngagementMetrics, Price: "0.15", UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, Extra: schemaExtra(analyticsInput, map[string]any{ "author": Bodyfield{Type: "string", Description: "Author identifier"}, "metrics": Bodyfield{Type: "array", Description: "Engagement metrics per post"}, }), }, { Name: "getPostingPatterns", Desc: "Get posting heatmap and patterns (hour-of-day x day-of-week)", Input: AnalyticsRequest{}, Output: PostingPatternsResponse{}, Handler: s.getPostingPatterns, Price: "0.10", UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, Extra: schemaExtra(analyticsInput, map[string]any{ "author": Bodyfield{Type: "string", Description: "Author identifier"}, "heatmap": Bodyfield{Type: "array", Description: "24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"}, "peakHours": Bodyfield{Type: "array", Description: "Top 3 hours with most posts (0-23)"}, "peakDays": Bodyfield{Type: "array", Description: "Top 3 days with most posts (0-6 = Sun-Sat)"}, "avgPostsPerDay": Bodyfield{Type: "number", Description: "Average posts per day"}, "totalPosts": Bodyfield{Type: "integer", Description: "Total posts in time range"}, "postingDays": Bodyfield{Type: "integer", Description: "Number of days with at least one post"}, }), }, { Name: "getTopicModeling", Desc: "Get LLM-based topic extraction and clustering from posts", Input: AnalyticsRequest{}, Output: TopicModelingResponse{}, Handler: s.getTopicModeling, Price: "0.20", UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, Extra: schemaExtra(analyticsInput, map[string]any{ "author": Bodyfield{Type: "string", Description: "Author identifier"}, "topics": Bodyfield{Type: "array", Description: "Identified topic clusters"}, }), }, { Name: "getSentimentTrends", Desc: "Get sentiment analysis and emotional arcs over time", Input: AnalyticsRequest{}, Output: SentimentTrendsResponse{}, Handler: s.getSentimentTrends, Price: "0.15", UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, Extra: schemaExtra(analyticsInput, map[string]any{ "author": Bodyfield{Type: "string", Description: "Author identifier"}, "timeSeries": Bodyfield{Type: "array", Description: "Sentiment over time"}, "overallSentiment": Bodyfield{Type: "object", Description: "Overall sentiment summary"}, "dominantEmotions": Bodyfield{Type: "array", Description: "Most frequent emotions"}, "sentimentShifts": Bodyfield{Type: "array", Description: "Significant sentiment changes"}, }), }, { Name: "getEngagementDrivers", Desc: "Get topics/keywords that correlate with high engagement", Input: AnalyticsRequest{}, Output: EngagementDriversResponse{}, Handler: s.getEngagementDrivers, Price: "0.20", UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, Extra: schemaExtra(analyticsInput, map[string]any{ "author": Bodyfield{Type: "string", Description: "Author identifier"}, "drivers": Bodyfield{Type: "array", Description: "Topics/keywords driving engagement"}, }), }, } for _, d := range payable { // Twitter-capable tools are advertised as upto (variable-priced) only // when the Twitter API is actually enabled: the upto maximum adds the // realized Twitter lookup and per-post costs, and the actual charge is // settled from the response. Without Twitter, every tool is exact at // its flat base price. scheme := "exact" price := d.Price var resolver x402.SettleAmountResolver if d.UsesTwitter && s.twitterEnabled { scheme = "upto" baseMicro := x402.MicroFromPriceString(d.Price) price = x402.FormatPriceMicro(x402.MaxTwitterCostMicro(baseMicro, d.TwitterPostCap)) if d.Name == "getStyleProfile" { resolver = x402.NewStyleProfileCostResolver() } else { resolver = x402.NewTwitterCountCostResolver(baseMicro) } } s.mcpHandler.AddPayableTool( mcp.Tool{Name: d.Name, Description: d.Desc, InputSchema: d.Input, OutputSchema: d.Output}, d.Handler, resolver, s.buildRequirements(price, d.Extra, scheme, d.MaxTimeoutSeconds)..., ) } // Free tool: getFeed s.mcpHandler.AddTool( mcp.Tool{Name: "getFeed", Description: "Get a feed by its URI", InputSchema: GetFeedRequest{}, OutputSchema: GetFeedResponse{}}, s.getFeed, ) } func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var args GetPostRequest if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } if args.Author == "" { return nil, fmt.Errorf("author must be a valid did or handle") } if args.RecordKey == "" { return nil, fmt.Errorf("rkey must be a valid record key") } platform, err := determinePlatform(args.Author) if err != nil { return nil, err } if platform == Twitter { return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") } post, err := s.fetchSinglePost(ctx, platform, args.Author, args.RecordKey, args.Collection) if err != nil { return nil, err } respBytes, err := json.Marshal(post) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(respBytes)}, }, }, nil } func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var args GetLatestPostsRequest if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } if args.Author == "" { return nil, fmt.Errorf("author must be a valid did or handle") } if args.Limit <= 0 { return nil, fmt.Errorf("n must be a positive integer") } if args.Limit > 50 { return nil, fmt.Errorf("n must be at most 50") } platform, err := determinePlatform(args.Author) if err != nil { return nil, err } if platform == Twitter { return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") } posts, authorID, err := s.fetchLatestPosts(ctx, platform, args.Author, args.Limit) if err != nil { return nil, err } if authorID == "" { authorID = args.Author } resp := GetLatestPostsResponse{ Author: authorID, Posts: posts, } respBytes, err := json.Marshal(resp) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(respBytes)}, }, }, nil } func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var args GetPostsInTimeRequest if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } if args.Author == "" { return nil, fmt.Errorf("author must be a valid did or handle") } if args.Limit <= 0 { return nil, fmt.Errorf("n must be a positive integer") } if args.Limit > 50 { return nil, fmt.Errorf("n must be at most 50") } limit := args.Limit startTime, err := time.Parse(time.RFC3339, args.StartTime) if err != nil { return nil, fmt.Errorf("startTime must be in RFC3339 format") } endTime, err := time.Parse(time.RFC3339, args.EndTime) if err != nil { return nil, fmt.Errorf("endTime must be in RFC3339 format") } if startTime.After(endTime) { tmp := startTime startTime = endTime endTime = tmp } platform, err := determinePlatform(args.Author) if err != nil { return nil, err } if platform == Twitter { return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") } posts, authorID, err := s.fetchPostsInTime(ctx, platform, args.Author, startTime, endTime, limit) if err != nil { return nil, err } if authorID == "" { authorID = args.Author } resp := GetPostsInTimeResponse{ Author: authorID, Posts: posts, } respBytes, err := json.Marshal(resp) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(respBytes)}, }, }, nil } func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var args GetFeedRequest if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } if args.URI == "" { return nil, fmt.Errorf("feedURI must be a valid feed URI") } platform, err := determinePlatform(args.URI) if err != nil { return nil, err } if platform == Twitter { return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling feed data") } posts, err := s.fetchFeed(ctx, platform, args.URI) if err != nil { return nil, err } resp := GetFeedResponse{ URI: args.URI, Posts: posts, } respBytes, err := json.Marshal(resp) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(respBytes)}, }, }, nil } func (s *Server) getActor(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var args GetActorRequest if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } if args.Author == "" { return nil, fmt.Errorf("author must be a valid did, handle, npub or @handle") } platform, err := determinePlatform(args.Author) if err != nil { return nil, err } var ( prof *ActorProfile e error ) switch platform { case Bluesky: prof, e = s.bskyClient.GetActor(ctx, args.Author) case ActivityPub: prof, e = s.apClient.GetActor(ctx, args.Author) case Nostr: prof, e = s.nostrClient.GetActor(ctx, args.Author) case Twitter: return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling user/profile data") default: return nil, fmt.Errorf("unsupported platform %q", platform) } if e != nil { return nil, e } respBytes, err := json.Marshal(GetActorResponse{Actor: *prof}) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(respBytes)}, }, }, nil } func (s *Server) searchActors(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var args SearchActorsRequest if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { return nil, fmt.Errorf("invalid arguments: %w", err) } if args.Query == "" { return nil, fmt.Errorf("query must not be empty") } limit := args.Limit if limit <= 0 { limit = 10 } if limit > 50 { limit = 50 } results, err := s.searchActorsAll(ctx, args.Query, limit) if err != nil { return nil, err } respBytes, err := json.Marshal(SearchActorsResponse{Results: results}) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: string(respBytes)}, }, }, nil }