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.
501 lines
19 KiB
Go
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
|
|
}
|