diff --git a/go.mod b/go.mod index 3a7d4da..34a475a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/fiatjaf/nak -go 1.23.1 +go 1.23.3 + +toolchain go1.23.4 require ( github.com/bep/debounce v1.2.1 @@ -13,6 +15,7 @@ require ( github.com/fiatjaf/khatru v0.15.0 github.com/json-iterator/go v1.1.12 github.com/mailru/easyjson v0.9.0 + github.com/mark3labs/mcp-go v0.8.3 github.com/markusmobius/go-dateparser v1.2.3 github.com/nbd-wtf/go-nostr v0.49.2 ) @@ -34,6 +37,7 @@ require ( github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 // indirect github.com/fasthttp/websocket v1.5.7 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect github.com/hablullah/go-hijri v1.0.2 // indirect github.com/hablullah/go-juliandays v1.0.0 // indirect diff --git a/go.sum b/go.sum index 82f770f..813b5dd 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc= github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q= @@ -112,6 +114,8 @@ github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg= +github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE= github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw= github.com/markusmobius/go-dateparser v1.2.3/go.mod h1:cMwQRrBUQlK1UI5TIFHEcvpsMbkWrQLXuaPNMFzuYLk= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= diff --git a/main.go b/main.go index 96d05c3..f2934fa 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ var app = &cli.Command{ decrypt, outbox, wallet, + mcpServer, }, Version: version, Flags: []cli.Flag{ diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..0538b18 --- /dev/null +++ b/mcp.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/fiatjaf/cli/v3" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/nbd-wtf/go-nostr" +) + +var mcpServer = &cli.Command{ + Name: "mcp", + Usage: "pander to the AI gods", + Description: ``, + DisableSliceFlagSeparator: true, + Flags: []cli.Flag{}, + Action: func(ctx context.Context, c *cli.Command) error { + s := server.NewMCPServer( + "nak", + version, + ) + + s.AddTool(mcp.NewTool("publish_note", + mcp.WithDescription("Publish a short note event to Nostr with the given text content"), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Arbitrary string to be published"), + ), + mcp.WithString("mention", + mcp.Required(), + mcp.Description("Nostr user's public key to be mentioned"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + content, _ := request.Params.Arguments["content"].(string) + mention, _ := request.Params.Arguments["mention"].(string) + + if mention != "" && !nostr.IsValidPublicKey(mention) { + return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil + } + + sk := os.Getenv("NOSTR_SECRET_KEY") + if sk == "" { + sk = "0000000000000000000000000000000000000000000000000000000000000001" + } + var relays []string + + evt := nostr.Event{ + Kind: 1, + Tags: nostr.Tags{{"client", "goose/nak"}}, + Content: content, + CreatedAt: nostr.Now(), + } + + if mention != "" { + evt.Tags = append(evt.Tags, nostr.Tag{"p", mention}) + // their inbox relays + relays = sys.FetchInboxRelays(ctx, mention, 3) + } + + evt.Sign(sk) + + // our write relays + relays = append(relays, sys.FetchOutboxRelays(ctx, evt.PubKey, 3)...) + + if len(relays) == 0 { + relays = []string{"nos.lol", "relay.damus.io"} + } + + for res := range sys.Pool.PublishMany(ctx, []string{"nos.lol"}, evt) { + if res.Error != nil { + return mcp.NewToolResultError( + fmt.Sprintf("there was an error publishing the event to the relay %s", + res.RelayURL), + ), nil + } + } + + return mcp.NewToolResultText("event was successfully published with id " + evt.ID), nil + }) + + s.AddTool(mcp.NewTool("search_profile", + mcp.WithDescription("Search for the public key of a Nostr user given their name"), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name to be searched"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, _ := request.Params.Arguments["name"].(string) + re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}}) + if re == nil { + return mcp.NewToolResultError("couldn't find anyone with that name"), nil + } + + return mcp.NewToolResultText(re.PubKey), nil + }) + + s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey", + mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"), + mcp.WithString("pubkey", + mcp.Required(), + mcp.Description("Public key of Nostr user we want to know the relay from where to read"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, _ := request.Params.Arguments["name"].(string) + re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}}) + if re == nil { + return mcp.NewToolResultError("couldn't find anyone with that name"), nil + } + + return mcp.NewToolResultText(re.PubKey), nil + }) + + s.AddTool(mcp.NewTool("read_events_from_relay", + mcp.WithDescription("Makes a REQ query to one relay using the specified parameters"), + mcp.WithNumber("kind", + mcp.Required(), + mcp.Description("event kind number to include in the 'kinds' field"), + ), + mcp.WithString("pubkey", + mcp.Description("pubkey to include in the 'authors' field"), + ), + mcp.WithNumber("limit", + mcp.Required(), + mcp.Description("maximum number of events to query"), + ), + mcp.WithString("relay", + mcp.Required(), + mcp.Description("relay URL to send the query to"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + relay, _ := request.Params.Arguments["relay"].(string) + limit, _ := request.Params.Arguments["limit"].(int) + kind, _ := request.Params.Arguments["kind"].(int) + pubkey, _ := request.Params.Arguments["pubkey"].(string) + + if pubkey != "" && !nostr.IsValidPublicKey(pubkey) { + return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil + } + + filter := nostr.Filter{ + Limit: limit, + Kinds: []int{kind}, + } + if pubkey != "" { + filter.Authors = []string{pubkey} + } + + events := sys.Pool.SubManyEose(ctx, []string{relay}, nostr.Filters{filter}) + + results := make([]string, 0, limit) + for ie := range events { + results = append(results, ie.String()) + } + + return mcp.NewToolResultText(strings.Join(results, "\n\n")), nil + }) + + return server.ServeStdio(s) + }, +}