naco-api/internal/server/tools.go
Nacorid f4ae7d9c9f
refactor(x402): fold resolver into AddPayableTool and honor per-tool timeouts
Simplify the payable-tool registration API by merging
AddPayableToolWithResolver into AddPayableTool, and thread a configurable
MaxTimeoutSeconds plus Extra through the payment options. getStyleProfile
keeps its 600s advertise timeout.
2026-09-13 22:24:34 +00:00

501 lines
19 KiB
Go

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
}