| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net" |
| 9 | "net/url" |
| 10 | "regexp" |
| 11 | "sort" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "time" |
| 15 | "unicode" |
| 16 | |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 18 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/gates" |
| 20 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 21 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 22 | "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 23 | "github.com/FelineStateMachine/tinyrelay/internal/wiki" |
| 24 | ) |
| 25 | |
| 26 | // The MCP tool table mirrors the browser tools in tinyclient/webmcp.js. |
| 27 | // Reads run through the browse methods, controls through the NIP-86 |
| 28 | // management path, and writes through the same publish path as POST /events, |
| 29 | // so every tool is executed as the signed-in pubkey with the tenant's |
| 30 | // permission checks. |
| 31 | |
| 32 | var ( |
| 33 | mcpText = map[string]any{"type" : "string" } |
| 34 | mcpPubKey = map[string]any{"type" : "string" , "pattern" : "^[0-9a-f]{64}$" , "description" : "Hex public key." } |
| 35 | mcpHash = map[string]any{"type" : "string" , "pattern" : "^[0-9a-f]{64}$" } |
| 36 | mcpID = map[string]any{"type" : "string" , "minLength" : 1} |
| 37 | mcpLimit = map[string]any{"type" : "integer" , "minimum" : 1, "maximum" : 100} |
| 38 | mcpCommit = map[string]any{"type" : "string" , "pattern" : "^[0-9a-f]{40}$" , "description" : "Full Git commit ID." } |
| 39 | mcpEvent = map[string]any{"type" : "object" , "description" : "A signed Nostr event: id, pubkey, created_at, kind, tags, content and sig." } |
| 40 | mcpRootKind = map[string]any{"type" : "integer" , "enum" : []int{1617, 1618, 1621}, "description" : "Kind of the conversation root: 1621 issue, 1618 pull request, 1617 patch." } |
| 41 | mcpRoomID = map[string]any{"type" : "string" , "pattern" : "^[a-z0-9_-]{1,64}$" , "description" : "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore." } |
| 42 | mcpPageName = map[string]any{"type" : "string" , "minLength" : 1, "description" : "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped." } |
| 43 | mcpUnixTime = map[string]any{"type" : "integer" , "minimum" : 1, "description" : "Unix time in seconds." } |
| 44 | mcpJobKind = map[string]any{"type" : "integer" , "minimum" : event.KIND_JOB_REQUEST_MIN, "maximum" : event.KIND_JOB_REQUEST_MAX, "not" : map[string]any{"enum" : []int{event.KIND_SITE_SNAPSHOT}}, "description" : "NIP-90 job request kind, 5000 to 5127 or 5129 to 5999." } |
| 45 | mcpMsats = map[string]any{"type" : "integer" , "minimum" : 0, "description" : "Amount in millisats." } |
| 46 | mcpJobStatus = map[string]any{"type" : "string" , "enum" : event.JobFeedbackStatuses} |
| 47 | mcpJobInputs = map[string]any{"type" : "array" , "items" : mcp.Object(map[string]any{"data" : mcpText, "type" : map[string]any{"type" : "string" , "enum" : event.JobInputTypes}, "relay" : mcpText, "marker" : mcpText}, "data" , "type" ), "description" : "Job inputs, each becoming an i tag." } |
| 48 | mcpReads = map[string]any{"readOnlyHint" : true, "openWorldHint" : false} |
| 49 | mcpChanges = map[string]any{"readOnlyHint" : false, "destructiveHint" : false, "idempotentHint" : false, "openWorldHint" : false} |
| 50 | mcpControls = map[string]any{"readOnlyHint" : false, "destructiveHint" : false, "idempotentHint" : true, "openWorldHint" : false} |
| 51 | mcpSettings = map[string]any{"readOnlyHint" : false, "destructiveHint" : true, "idempotentHint" : true, "openWorldHint" : false} |
| 52 | mcpPublishes = map[string]any{"readOnlyHint" : false, "destructiveHint" : false, "idempotentHint" : true, "openWorldHint" : true} |
| 53 | |
| 54 | mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$` ) |
| 55 | mcpWikiCoordinate = regexp.MustCompile(`^30818:[0-9a-f]{64}:.+$` ) |
| 56 | mcpReadMethods = []string{"stats" , "getpolicy" , "listaudit" , "listjobs" , "listbackups" , "listdumps" , "deliverystatus" , "storagestats" , "gitstorage" , "listconnections" , "listmembers" } |
| 57 | mcpStatusKinds = map[string]int{"open" : 1630, "resolved" : 1631, "merged" : 1631, "closed" : 1632, "draft" : 1633} |
| 58 | mcpRequestKinds = []string{"approve" , "decide" , "question" } |
| 59 | ) |
| 60 | |
| 61 | const ( |
| 62 | mcpIssueShape = `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"] and optional ["t","<label>"] tags, with the Markdown description in content.` |
| 63 | mcpPullShape = `Expected a signed kind 1618 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"], ["c","<40-hex commit>"], ["clone","<http or https URL>"], optional ["merge-base","<40-hex commit>"] and optional ["t","<label>"] tags, with the description in content.` |
| 64 | mcpCommentShape = `Expected a signed kind 1111 event with NIP-22 tags ["a","30617:<owner>:<repo>"], ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<parent id>","","<parent pubkey>"], ["k","<parent kind>"], ["p","<parent pubkey>"], with the comment in content. The parent is the root for a top-level comment. A review comment under a pull request or patch may add ["file","<path>"] and ["line","<n>","old" or "new"] to name one diff line.` |
| 65 | mcpStatusShape = `Expected a signed event of kind 1630 (open), 1631 (resolved or merged), 1632 (closed) or 1633 (draft) with tags ["a","30617:<owner>:<repo>"], ["e","<root id>","","root"] and ["p","<root pubkey>"].` |
| 66 | mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.` |
| 67 | mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.` |
| 68 | mcpReplyShape = `Expected a signed kind 12 event with tags ["h","<room id>"], ["e","<thread root id>"] and optional ["p","<root author>"], with the reply in content.` |
| 69 | mcpReactShape = `Expected a signed kind 7 event with tags ["e","<event id>"], ["p","<event author>"] and, for a room message, ["h","<room id>"], with "+", "-" or one emoji in content.` |
| 70 | mcpWikiShape = `Expected a signed kind 30818 event with tags ["d","<normalized page name>"], ["title","<title>"], optional ["summary","<summary>"] and, for a fork, ["a","30818:<author>:<page name>","","fork"] and ["e","<version id>","","fork"], with the Djot article in content.` |
| 71 | mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.` |
| 72 | mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].` |
| 73 | mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"] and optional ["e","<thread root id>","","root"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.` |
| 74 | mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional ["h","<room id>"], ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.` |
| 75 | mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"] and ["amount","<millisats>","<bolt11>"], with content empty or a partial result.` |
| 76 | mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"], ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.` |
| 77 | mcpSiteShape = `Expected a signed kind 15128 event for your own site, or a signed kind 35128 event with ["d","<site name>"] for a named site under your key, with one ["path","/<file path>","<sha256 of the file>"] tag per file, the blobs already uploaded, and an optional ["expiration","<unix time>"] tag.` |
| 78 | mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument." |
| 79 | mcpAnswerNote = " The asked key answers with a kind 7 reaction (+ approves, - declines) or a kind 1111 NIP-22 reply. Query kinds 7 and 1111 with #e set to the published request's event id to collect answers. A question's text answer is a reply, not an arbitrary reaction." |
| 80 | ) |
| 81 | |
| 82 | // mcpUnsigned is the event body a client signs before calling a write tool |
| 83 | // again. |
| 84 | type mcpUnsigned struct { |
| 85 | Kind int `json:"kind"` |
| 86 | CreatedAt int64 `json:"created_at"` |
| 87 | Tags [][]string `json:"tags"` |
| 88 | Content string `json:"content"` |
| 89 | } |
| 90 | |
| 91 | func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 92 | registry := &mcp.Registry{} |
| 93 | var err error |
| 94 | add := func(name, description string, schema, annotations map[string]any, handler mcp.Handler) { |
| 95 | if err == nil { |
| 96 | err = registry.Add(mcp.Tool{Name: name, Description: description, InputSchema: schema, Annotations: annotations, Handler: handler}) |
| 97 | } |
| 98 | } |
| 99 | repository := map[string]any{"owner" : mcpPubKey, "repo" : mcpID, "ref" : mcpText, "path" : mcpText, "view" : map[string]any{"type" : "string" , "enum" : []string{"tree" , "file" , "history" , "commit" , "activity" }}} |
| 100 | collaborationList := mcp.Object(map[string]any{"owner" : mcpPubKey, "repo" : mcpID, "cursor" : mcpText, "limit" : mcpLimit, "q" : mcpText, "label" : mcpText, "state" : map[string]any{"type" : "string" , "enum" : []string{"open" , "resolved" , "merged" , "closed" , "draft" }}}, "owner" , "repo" ) |
| 101 | collaborationDetail := mcp.Object(map[string]any{"owner" : mcpPubKey, "repo" : mcpID, "event" : mcpHash, "cursor" : mcpText, "limit" : mcpLimit}, "owner" , "repo" , "event" ) |
| 102 | |
| 103 | add("list_repositories" , "List repositories visible to your account. Supports search and pagination." , mcp.Object(map[string]any{"cursor" : mcpText, "limit" : mcpLimit, "q" : mcpText}), mcpReads, t.mcpBrowse("browserepos" )) |
| 104 | add("read_repository" , "Read a repository tree, source file, history, commit diff or activity. Use ref to select a branch, tag or commit." , mcp.Object(merge(repository, map[string]any{"offset" : map[string]any{"type" : "integer" , "minimum" : 0}, "limit" : mcpLimit}), "owner" , "repo" ), mcpReads, t.mcpBrowse("browserepo" )) |
| 105 | add("list_issues" , "List repository issues with search, status filters and pagination." , collaborationList, mcpReads, t.mcpBrowse("browseissues" )) |
| 106 | add("read_issue" , "Read an issue, its replies and authorized status changes." , collaborationDetail, mcpReads, t.mcpBrowse("browseissue" )) |
| 107 | add("list_pull_requests" , "List repository pull requests with search, status filters and pagination." , collaborationList, mcpReads, t.mcpBrowse("browsepulls" )) |
| 108 | add("read_pull_request" , "Read a pull request, its replies, authorized updates and available diff." , collaborationDetail, mcpReads, t.mcpBrowse("browsepull" )) |
| 109 | add("list_files" , "List stored files visible to your account." , mcp.Object(map[string]any{"cursor" : mcpText, "limit" : mcpLimit, "q" : mcpText}), mcpReads, t.mcpBrowse("browsefiles" )) |
| 110 | add("read_file" , "Read a stored file's metadata and available preview by SHA-256 hash." , mcp.Object(map[string]any{"hash" : mcpHash}, "hash" ), mcpReads, t.mcpBrowse("browsefile" )) |
| 111 | add("upload_attachment" , "Upload a bounded file for a room message. Returns its Blossom URL and NIP-92 metadata; pass the descriptor as an attachment to a room write tool." , mcp.Object(map[string]any{"room" : mcpRoomID, "data" : map[string]any{"type" : "string" , "description" : "Standard Base64 file bytes, at most 700 KiB of encoded data. Use PUT /rooms/<id>/attachments for larger files." }, "type" : map[string]any{"type" : "string" , "minLength" : 1, "maxLength" : 255}, "filename" : map[string]any{"type" : "string" , "minLength" : 1, "maxLength" : 255}}, "room" , "data" , "type" ), mcpPublishes, t.mcpUploadAttachment) |
| 112 | add("read_attachment" , "Read a stored attachment your key may access. Images and audio are returned as native MCP content; other files return Base64 data. Reads are limited to 4 MiB." , mcp.Object(map[string]any{"sha256" : mcpHash, "max_bytes" : map[string]any{"type" : "integer" , "minimum" : 1, "maximum" : 4 * 1024 * 1024}}, "sha256" ), mcpReads, t.mcpReadAttachment) |
| 113 | add("read_status" , "Read service health, storage and job status. Requires an owner or moderator key." , mcp.Object(nil), mcpReads, t.mcpBrowse("browsestatus" )) |
| 114 | add("read_management" , "Read relay configuration, jobs, backups, delivery status or members with your key's permissions. gitstorage requires owner and repo." , mcp.Object(map[string]any{"method" : map[string]any{"type" : "string" , "enum" : mcpReadMethods}, "owner" : mcpPubKey, "repo" : mcpID}, "method" ), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 115 | method := call.String("method" ) |
| 116 | if method == "gitstorage" { |
| 117 | if call.String("owner" ) == "" || call.String("repo" ) == "" { |
| 118 | return mcp.Failure("gitstorage needs owner and repo." , nil), nil |
| 119 | } |
| 120 | return t.mcpManage(ctx, call, method, call.String("owner" ), call.String("repo" )) |
| 121 | } |
| 122 | return t.mcpManage(ctx, call, method) |
| 123 | }) |
| 124 | add("list_rooms" , "List chat rooms visible to your account with member counts and the time of the last message." , mcp.Object(map[string]any{"cursor" : mcpText, "limit" : mcpLimit}), mcpReads, t.mcpBrowse("browserooms" )) |
| 125 | add("read_room" , "Read a room, its members and its newest messages, threads and reactions. Pass cursor from next_cursor for older messages." , mcp.Object(map[string]any{"id" : mcpRoomID, "cursor" : mcpText, "limit" : mcpLimit}, "id" ), mcpReads, t.mcpBrowse("browseroom" )) |
| 126 | add("read_thread" , "Read a thread root and its replies in a room, newest first." , mcp.Object(map[string]any{"id" : mcpRoomID, "event" : mcpHash, "cursor" : mcpText, "limit" : mcpLimit}, "id" , "event" ), mcpReads, t.mcpBrowse("browsethread" )) |
| 127 | add("list_wiki" , "List wiki pages with their preferred version. q searches titles and summaries; author prefers that key's versions." , mcp.Object(map[string]any{"q" : mcpText, "author" : mcpPubKey, "cursor" : mcpText, "limit" : mcpLimit}), mcpReads, t.mcpBrowse("browsewiki" )) |
| 128 | add("read_wiki_page" , "Read a wiki page: its preferred version with content, every version, the history of revisions with their states, merge requests and redirects. Pass author to prefer that key's version or version to open one by event id, including an archived revision from the history." , mcp.Object(map[string]any{"d" : mcpPageName, "author" : mcpPubKey, "version" : mcpHash}, "d" ), mcpReads, t.mcpBrowse("browsewikipage" )) |
| 129 | add("read_merge_request" , "Read a wiki merge request with its status, the proposed version and the target version." , mcp.Object(map[string]any{"id" : mcpHash}, "id" ), mcpReads, t.mcpBrowse("browsewikimerge" )) |
| 130 | add("list_agents" , "List granted agents with their name, key, owner, expiration, paused and revoked state, scope and last event. Requires an owner or moderator key." , mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 131 | return t.mcpManage(ctx, call, "listagents" ) |
| 132 | }) |
| 133 | add("list_jobs" , "List long task requests (NIP-90 job requests) visible to your key, each with its newest feedback status and its result when one exists. state narrows the list to open, done or all; mine lists only your own requests." , mcp.Object(map[string]any{"cursor" : mcpText, "limit" : mcpLimit, "state" : map[string]any{"type" : "string" , "enum" : jobStates}, "mine" : map[string]any{"type" : "boolean" }}), mcpReads, t.mcpBrowse("browsejobs" )) |
| 134 | add("read_job" , "Read one long task request by event id with its feedback timeline and results." , mcp.Object(map[string]any{"id" : mcpHash}, "id" ), mcpReads, t.mcpBrowse("browsejob" )) |
| 135 | add("list_callbacks" , "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed." , mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 136 | return t.mcpManage(ctx, call, "listcallbacks" ) |
| 137 | }) |
| 138 | add("list_custom_views" , "List the owner's custom views: name, kinds, languages, transform host, trigger, audience, state, failures and last run. The secret is never listed. Owner only." , mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 139 | return t.mcpManage(ctx, call, "listcustomviews" ) |
| 140 | }) |
| 141 | add("list_join_requests" , "List access requests from people who asked to join without an invite: pubkey, reason, requested_at, status (pending, approved or denied), decided_by and decided_at. Pending requests come first. Requires an owner or moderator key." , mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 142 | return t.mcpManage(ctx, call, "listjoinrequests" ) |
| 143 | }) |
| 144 | |
| 145 | add("run_job" , "Queue an existing job to run now. Read jobs through read_management to check its completion." , mcp.Object(map[string]any{"id" : mcpID}, "id" ), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 146 | return t.mcpManage(ctx, call, "runjob" , call.String("id" )) |
| 147 | }) |
| 148 | add("add_job" , "Add a background job. every is the interval in hours; 0 runs once. Pull and push jobs need ws:// or wss:// relay URLs; pull may instead discover relays for a public key. Dump and backup jobs may omit relays." , mcp.Object(map[string]any{"id" : mcpID, "kind" : map[string]any{"type" : "string" , "enum" : []string{"pull" , "push" , "import" , "mirror" , "dump" , "backup" }}, "relays" : map[string]any{"type" : "array" , "items" : mcpText}, "filter" : map[string]any{"type" : "string" , "description" : "Nostr filter encoded as a JSON object string." }, "every" : map[string]any{"type" : "integer" , "minimum" : 0, "description" : "Interval in hours. Zero runs once." }, "discoverPubKey" : mcpPubKey}, "id" , "kind" ), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 149 | return t.mcpManage(ctx, call, "addjob" , call.Arguments) |
| 150 | }) |
| 151 | add("remove_job" , "Remove a job and cancel its pending runs." , mcp.Object(map[string]any{"id" : mcpID}, "id" ), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 152 | return t.mcpManage(ctx, call, "removejob" , call.String("id" )) |
| 153 | }) |
| 154 | add("backup_now" , "Queue a backup of relay data. Read jobs and backups through read_management to check completion." , mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 155 | return t.mcpManage(ctx, call, "backupnow" ) |
| 156 | }) |
| 157 | add("dump_now" , "Queue an event export. Read jobs and dumps through read_management to check completion." , mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 158 | return t.mcpManage(ctx, call, "dumpnow" ) |
| 159 | }) |
| 160 | add("set_policy" , "Apply a partial relay policy update. Read the current policy before changing access, delivery or features. Requires the owner key." , mcp.Object(map[string]any{"patch" : map[string]any{"type" : "object" , "minProperties" : 1}}, "patch" ), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 161 | return t.mcpManage(ctx, call, "setpolicy" , call.Arguments["patch" ]) |
| 162 | }) |
| 163 | add("set_connections" , "Replace the relay's connection list. Read the current list with read_management listconnections before editing it." , mcp.Object(map[string]any{"connections" : map[string]any{"type" : "array" , "items" : map[string]any{"type" : "object" }}}, "connections" ), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 164 | return t.mcpManage(ctx, call, "setconnections" , call.Arguments["connections" ]) |
| 165 | }) |
| 166 | add("send_test_notification" , "Send the relay's test notice to the owner's inbox and enabled devices. Owner only." , mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 167 | return t.mcpManage(ctx, call, "notifytest" ) |
| 168 | }) |
| 169 | add("add_callback" , "Register an https URL that receives a POST with each new event matching filter that your key may see. The filter takes kinds (required), authors, #a, #e, #p and #h with at most 8 values each. The answer carries the callback id and, once, the secret used for the X-Tiny-Signature header; keep it. Members and agents may hold up to the relay's callback allowance, 4 by default." , mcp.Object(map[string]any{"url" : map[string]any{"type" : "string" , "description" : "An https URL on a public host." }, "filter" : map[string]any{"type" : "object" , "description" : "NIP-01 filter subset: kinds, authors, #a, #e, #p, #h." , "minProperties" : 1}, "secret" : map[string]any{"type" : "string" , "minLength" : callbackSecretMin, "maxLength" : callbackSecretMax, "description" : "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted." }}, "url" , "filter" ), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 170 | return t.mcpManage(ctx, call, "addcallback" , call.Arguments) |
| 171 | }) |
| 172 | add("add_custom_view" , "Define a custom view: the relay POSTs the fenced code blocks of matching events, in the listed languages, to an https transform and keeps the SVG or PNG artifacts it returns as relay-signed events shown in place of the blocks. The answer carries the secret once, used for the X-Transform-Signature header; keep it. Owner only." , mcp.Object(map[string]any{"name" : map[string]any{"type" : "string" , "pattern" : "^[a-z0-9-]{1,32}$" , "description" : "View name: 1 to 32 lowercase letters, digits or hyphens." }, "kinds" : map[string]any{"type" : "array" , "items" : map[string]any{"type" : "integer" , "minimum" : 0, "maximum" : 65535}, "minItems" : 1, "maxItems" : viewKindMax, "description" : "Event kinds the view watches; 30618 sends the repository README at its head." }, "transform" : map[string]any{"type" : "string" , "description" : "An https URL on a public host that renders the blocks." }, "trigger" : map[string]any{"type" : "string" , "enum" : []string{"write" , "hourly" }, "description" : "write sends each matching event as it arrives; hourly sends the newest ones every hour." }, "audience" : map[string]any{"type" : "string" , "enum" : []string{"public" , "members" }, "description" : "Who may see the artifacts." }, "languages" : map[string]any{"type" : "array" , "items" : map[string]any{"type" : "string" , "minLength" : 1, "maxLength" : viewLanguageLength}, "minItems" : 1, "maxItems" : viewLanguageMax, "description" : "Fenced block languages the view renders, lowercase." }, "max_bytes" : map[string]any{"type" : "integer" , "minimum" : 1, "maximum" : viewMaxBytesCap, "description" : "Largest artifact accepted, in bytes. 1 MiB when omitted, 4 MiB at most." }, "secret" : map[string]any{"type" : "string" , "minLength" : callbackSecretMin, "maxLength" : callbackSecretMax, "description" : "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted." }}, "name" , "kinds" , "transform" , "languages" ), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 173 | return t.mcpManage(ctx, call, "addcustomview" , call.Arguments) |
| 174 | }) |
| 175 | add("remove_custom_view" , "Delete a custom view and every artifact it produced. Owner only." , mcp.Object(map[string]any{"name" : mcpID}, "name" ), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 176 | return t.mcpManage(ctx, call, "removecustomview" , call.String("name" )) |
| 177 | }) |
| 178 | add("pause_custom_view" , "Stop a custom view: no transform requests until it is resumed. Its artifacts stay. Owner only." , mcp.Object(map[string]any{"name" : mcpID}, "name" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 179 | return t.mcpManage(ctx, call, "pausecustomview" , call.String("name" )) |
| 180 | }) |
| 181 | add("resume_custom_view" , "Resume a paused custom view and clear its failure count. Owner only." , mcp.Object(map[string]any{"name" : mcpID}, "name" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 182 | return t.mcpManage(ctx, call, "resumecustomview" , call.String("name" )) |
| 183 | }) |
| 184 | add("run_custom_view" , "Queue a backfill of a custom view over the newest 500 events of its kinds. Blocks that already have an artifact are reused unless refresh is true, which rebuilds them from the transform. Owner only." , mcp.Object(map[string]any{"name" : mcpID, "refresh" : map[string]any{"type" : "boolean" , "description" : "Rebuild existing artifacts from the transform." }}, "name" ), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 185 | return t.mcpManage(ctx, call, "runcustomview" , call.Arguments) |
| 186 | }) |
| 187 | add("remove_callback" , "Delete a callback by id. The callback's owner, the relay owner and moderators may do this." , mcp.Object(map[string]any{"id" : mcpID}, "id" ), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 188 | return t.mcpManage(ctx, call, "removecallback" , call.String("id" )) |
| 189 | }) |
| 190 | add("pause_callback" , "Stop deliveries to a callback until it is resumed. The callback's owner, the relay owner and moderators may do this." , mcp.Object(map[string]any{"id" : mcpID}, "id" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 191 | return t.mcpManage(ctx, call, "pausecallback" , call.String("id" )) |
| 192 | }) |
| 193 | add("resume_callback" , "Resume a paused callback and clear its failure count." , mcp.Object(map[string]any{"id" : mcpID}, "id" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 194 | return t.mcpManage(ctx, call, "resumecallback" , call.String("id" )) |
| 195 | }) |
| 196 | add("pause_agent" , "Stop an agent from publishing until it is resumed. The grant stays in place. Requires an owner or moderator key." , mcp.Object(map[string]any{"agent" : mcpPubKey}, "agent" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 197 | return t.mcpManage(ctx, call, "pauseagent" , call.String("agent" )) |
| 198 | }) |
| 199 | add("resume_agent" , "Let a paused agent publish again. Requires an owner or moderator key." , mcp.Object(map[string]any{"agent" : mcpPubKey}, "agent" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 200 | return t.mcpManage(ctx, call, "resumeagent" , call.String("agent" )) |
| 201 | }) |
| 202 | add("revoke_agent" , "End an agent's grant and remove its agent role. The grant event stays stored for audit; a fresh grant restores access. Requires an owner or moderator key." , mcp.Object(map[string]any{"agent" : mcpPubKey}, "agent" ), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 203 | return t.mcpManage(ctx, call, "revokeagent" , call.String("agent" )) |
| 204 | }) |
| 205 | add("approve_join" , "Approve an access request: the key becomes a member, the relay's member list and add-user record follow, and the request is marked approved. Requires an owner or moderator key." , mcp.Object(map[string]any{"pubkey" : mcpPubKey}, "pubkey" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 206 | return t.mcpManage(ctx, call, "approvejoin" , call.String("pubkey" )) |
| 207 | }) |
| 208 | add("deny_join" , "Deny an access request. The key stays outside the relay and may ask again. Requires an owner or moderator key." , mcp.Object(map[string]any{"pubkey" : mcpPubKey}, "pubkey" ), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 209 | return t.mcpManage(ctx, call, "denyjoin" , call.String("pubkey" )) |
| 210 | }) |
| 211 | add("pause_all_agents" , "Pause every active agent at once. Requires an owner or moderator key." , mcp.Object(nil), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 212 | return t.mcpManage(ctx, call, "pauseallagents" ) |
| 213 | }) |
| 214 | add("resume_all_agents" , "Resume every paused agent. Requires an owner or moderator key." , mcp.Object(nil), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 215 | return t.mcpManage(ctx, call, "resumeallagents" ) |
| 216 | }) |
| 217 | |
| 218 | add("publish_event" , "Publish a signed Nostr event to this relay. The relay's access rules apply as they do for POST /events." , mcp.Object(map[string]any{"event" : mcpEvent}, "event" ), mcpPublishes, t.mcpWrite(nil, nil, "Expected a signed Nostr event." )) |
| 219 | add("create_issue" , "Open an issue on a hosted repository. Pass owner, repo, title and content to receive the unsigned kind 1621 event, sign it, then call again with the signed event." , mcp.Object(map[string]any{"event" : mcpEvent, "owner" : mcpPubKey, "repo" : mcpID, "title" : mcpText, "content" : mcpText, "labels" : map[string]any{"type" : "array" , "items" : mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildIssue, mcpCheckIssue, mcpIssueShape)) |
| 220 | add("create_pull_request" , "Open a pull request on a hosted repository. Pass owner, repo, title, commit and clone (plus optional content, merge_base and labels) to receive the unsigned kind 1618 event, sign it, then call again with the signed event." , mcp.Object(map[string]any{"event" : mcpEvent, "owner" : mcpPubKey, "repo" : mcpID, "title" : mcpText, "content" : mcpText, "commit" : mcpCommit, "clone" : map[string]any{"type" : "string" , "description" : "HTTP or HTTPS clone URL without credentials." }, "merge_base" : mcpCommit, "labels" : map[string]any{"type" : "array" , "items" : mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildPull, mcpCheckPull, mcpPullShape)) |
| 221 | add("comment" , "Reply to an issue, pull request or comment with a NIP-22 kind 1111 event. Pass owner, repo, root, root_kind, root_pubkey and content (plus parent, parent_kind and parent_pubkey to answer a comment) to receive the unsigned event, sign it, then call again with the signed event. To review one line of a pull request or patch diff, add file, line and side (old for the base, new for the change)." , mcp.Object(map[string]any{"event" : mcpEvent, "owner" : mcpPubKey, "repo" : mcpID, "root" : mcpHash, "root_kind" : mcpRootKind, "root_pubkey" : mcpPubKey, "parent" : mcpHash, "parent_kind" : map[string]any{"type" : "integer" , "enum" : []int{1111, 1617, 1618, 1621}}, "parent_pubkey" : mcpPubKey, "content" : mcpText, "file" : map[string]any{"type" : "string" , "minLength" : 1, "description" : "Path of the diff file the comment is about." }, "line" : map[string]any{"type" : "integer" , "minimum" : 1, "description" : "Line number on the chosen side of the diff." }, "side" : map[string]any{"type" : "string" , "enum" : []string{"old" , "new" }}}), mcpPublishes, t.mcpWrite(mcpBuildComment, mcpCheckComment, mcpCommentShape)) |
| 222 | add("set_status" , "Change the status of an issue or pull request. The author, repository owner and maintainers may do this. Pass owner, repo, root, root_pubkey and status to receive the unsigned event, sign it, then call again with the signed event." , mcp.Object(map[string]any{"event" : mcpEvent, "owner" : mcpPubKey, "repo" : mcpID, "root" : mcpHash, "root_pubkey" : mcpPubKey, "status" : map[string]any{"type" : "string" , "enum" : []string{"open" , "resolved" , "merged" , "closed" , "draft" }}}), mcpPublishes, t.mcpWrite(mcpBuildStatus, mcpCheckStatus, mcpStatusShape)) |
| 223 | attachments := map[string]any{"type" : "array" , "items" : mcpAttachment, "maxItems" : 8} |
| 224 | add("post_message" , "Post a kind 9 chat message in a room. Pass room and content or attachments, plus optional mentions, to receive an unsigned event. Sign it, then call again with event. Posting in an open room joins it." , mcp.Object(map[string]any{"event" : mcpEvent, "room" : mcpRoomID, "content" : mcpText, "mentions" : map[string]any{"type" : "array" , "items" : mcpPubKey}, "attachments" : attachments}), mcpPublishes, t.mcpWrite(mcpBuildMessage, mcpCheckMessage, mcpMessageShape)) |
| 225 | add("start_thread" , "Start a kind 11 thread in a room. Pass room and content or attachments, plus an optional title, to receive an unsigned event. Sign it, then call again with event." , mcp.Object(map[string]any{"event" : mcpEvent, "room" : mcpRoomID, "title" : mcpText, "content" : mcpText, "attachments" : attachments}), mcpPublishes, t.mcpWrite(mcpBuildThread, mcpCheckThread, mcpThreadShape)) |
| 226 | add("reply_in_thread" , "Reply with a kind 12 event. Pass room, root and content or attachments, plus optional root_pubkey, to receive an unsigned event. A nested root is resolved to the thread root before signing. Sign it, then call again with event." , mcp.Object(map[string]any{"event" : mcpEvent, "room" : mcpRoomID, "root" : mcpHash, "root_pubkey" : mcpPubKey, "content" : mcpText, "attachments" : attachments}), mcpPublishes, t.mcpWrite(mcpBuildReply, mcpCheckReply, mcpReplyShape)) |
| 227 | add("react" , "React to an event with a kind 7 reaction: + to like or approve, - to dislike or decline, or one emoji. Pass target, target_pubkey and content (plus room for a room message) to receive the unsigned event, sign it, then call again with the signed event. A + or - from a wiki merge request's destination author answers the request." , mcp.Object(map[string]any{"event" : mcpEvent, "target" : mcpHash, "target_pubkey" : mcpPubKey, "content" : map[string]any{"type" : "string" , "minLength" : 1, "description" : "+, - or one emoji." }, "room" : mcpRoomID}), mcpPublishes, t.mcpWrite(mcpBuildReact, mcpCheckReact, mcpReactShape)) |
| 228 | add("publish_wiki_page" , "Publish or replace your version of a kind 30818 wiki page in Djot markup. Pass d (the page name), title and content, plus optional summary and, to fork another author's version, fork_author and fork_event, to receive the unsigned event, sign it, then call again with the signed event." , mcp.Object(map[string]any{"event" : mcpEvent, "d" : mcpPageName, "title" : mcpText, "summary" : mcpText, "content" : mcpText, "fork_author" : mcpPubKey, "fork_event" : mcpHash}), mcpPublishes, t.mcpWrite(mcpBuildWikiPage, mcpCheckWikiPage, mcpWikiShape)) |
| 229 | add("propose_wiki_merge" , "Ask a wiki author to take in changes from another version with a kind 818 merge request. Pass d, destination (the author asked), source (the proposed version's event id) and content, plus an optional base version id, to receive the unsigned event, sign it, then call again with the signed event. The destination author answers with a + or - reaction." , mcp.Object(map[string]any{"event" : mcpEvent, "d" : mcpPageName, "destination" : mcpPubKey, "source" : mcpHash, "base" : mcpHash, "content" : mcpText}), mcpPublishes, t.mcpWrite(mcpBuildWikiMerge, mcpCheckWikiMerge, mcpMergeShape)) |
| 230 | add("publish_site" , "Publish a static site manifest (NIP-5A). Upload the files to the blob store first, then pass paths as [path, sha256] pairs, an optional label (your npub for your own site, the default, or a named site label under your key) and an optional expiration, to receive the unsigned kind 15128 or 35128 event, sign it, then call again with the signed event. An agent needs a sites grant that covers the label; a grant with a ttl requires the expiration." , mcp.Object(map[string]any{"event" : mcpEvent, "label" : map[string]any{"type" : "string" , "minLength" : 1, "description" : "Site label: your npub, or a named site label under your key. Defaults to your own site." }, "paths" : map[string]any{"type" : "array" , "items" : map[string]any{"type" : "array" , "items" : mcpText}, "description" : "One [path, sha256] pair per file, such as [\"/index.html\", \"<sha256>\"]." }, "expiration" : mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildSite, mcpCheckSite, mcpSiteShape)) |
| 231 | add("create_room" , "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner." , mcp.Object(map[string]any{"event" : mcpEvent, "room" : mcpRoomID, "name" : mcpText, "about" : mcpText, "visibility" : map[string]any{"type" : "string" , "enum" : []string{"open" , "members" }}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape)) |
| 232 | add("request_decision" , "Ask a person for an approval, a decision or an answer. Pass pubkey, request and content, plus room and optional root for a kind 9 room message, or root, root_kind and root_pubkey for a kind 1111 comment under another event. A room root is resolved to the thread root before signing." +mcpAnswerNote, mcp.Object(map[string]any{"event" : mcpEvent, "pubkey" : mcpPubKey, "request" : map[string]any{"type" : "string" , "enum" : mcpRequestKinds}, "content" : mcpText, "room" : mcpRoomID, "root" : mcpHash, "root_kind" : map[string]any{"type" : "integer" , "minimum" : 0}, "root_pubkey" : mcpPubKey, "expiration" : mcpUnixTime, "subject" : mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape)) |
| 233 | add("request_job" , "Ask for a long task with a NIP-90 job request. Pass kind, inputs and optional room, output, params, bid in millisats, relays and expiration. A room-scoped job keeps its h tag on feedback and results; read them with read_job." , mcp.Object(map[string]any{"event" : mcpEvent, "kind" : mcpJobKind, "room" : mcpRoomID, "inputs" : mcpJobInputs, "output" : map[string]any{"type" : "string" , "description" : "Expected output MIME type." }, "params" : map[string]any{"type" : "object" , "description" : "Job parameters as key and string value, each becoming a param tag." }, "bid" : mcpMsats, "relays" : map[string]any{"type" : "array" , "items" : mcpText}, "expiration" : mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape)) |
| 234 | add("job_feedback" , "Report progress on a long task with a kind 7000 job feedback event. Pass e, p, status and optional room, info, amount in millisats, invoice and content. For a room-scoped request, pass the same room so private feedback stays private." , mcp.Object(map[string]any{"event" : mcpEvent, "e" : mcpHash, "p" : mcpPubKey, "room" : mcpRoomID, "status" : mcpJobStatus, "info" : mcpText, "amount" : mcpMsats, "invoice" : mcpText, "content" : mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape)) |
| 235 | add("job_result" , "Deliver a long task's output with a NIP-90 job result. Pass request (the job request event) or kind, e and p, plus content and optional room, amount and invoice. When request is supplied, its room and inputs are copied into the result." , mcp.Object(map[string]any{"event" : mcpEvent, "request" : map[string]any{"type" : "object" , "description" : "The job request event being answered." }, "room" : mcpRoomID, "kind" : map[string]any{"type" : "integer" , "minimum" : event.KIND_JOB_RESULT_MIN, "maximum" : event.KIND_JOB_RESULT_MAX}, "e" : mcpHash, "p" : mcpPubKey, "content" : mcpText, "amount" : mcpMsats, "invoice" : mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobResult, mcpCheckJobResult, mcpJobResultShape)) |
| 236 | add("request_grant" , "Ask your current grant operator for agent permission changes. Pass reason (1 to 500 Unicode characters) and changes. Kinds and rooms are added; repos and sites replace matching entries; wiki, jobs and rate replace their fields. Other fields stay unchanged. Sign the returned kind 1111 request and call again with event. Only the operator's signed kind 30392 replacement grants access; a + reaction does not." , mcpGrantRequestSchema(), mcpPublishes, t.mcpGrantRequest) |
| 237 | return registry, err |
| 238 | } |
| 239 | |
| 240 | func (t *Tenant) mcpBrowse(method string) mcp.Handler { |
| 241 | return func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 242 | raw, err := json.Marshal(call.Arguments) |
| 243 | if err != nil { |
| 244 | return mcp.Result{}, err |
| 245 | } |
| 246 | result, err := t.Execute(ctx, call.Actor, method, []json.RawMessage{raw}) |
| 247 | if err != nil { |
| 248 | return mcp.Result{}, err |
| 249 | } |
| 250 | return mcp.Value(result), nil |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | func (t *Tenant) mcpManage(ctx context.Context, call mcp.Call, method string, params ...any) (mcp.Result, error) { |
| 255 | raw := make([]json.RawMessage, 0, len(params)) |
| 256 | for _, param := range params { |
| 257 | encoded, err := json.Marshal(param) |
| 258 | if err != nil { |
| 259 | return mcp.Result{}, err |
| 260 | } |
| 261 | raw = append(raw, encoded) |
| 262 | } |
| 263 | result, err := t.Execute(ctx, call.Actor, method, raw) |
| 264 | if err != nil { |
| 265 | return mcp.Result{}, err |
| 266 | } |
| 267 | return mcp.Value(map[string]any{"result" : result}), nil |
| 268 | } |
| 269 | |
| 270 | // mcpWrite serves a write tool. A signed event argument is checked against |
| 271 | // the expected shape and published; without one the builder returns the |
| 272 | // unsigned event for the client to sign. |
| 273 | func (t *Tenant) mcpWrite(build func(mcp.Call) (mcpUnsigned, error), check func(event.Event) error, shape string) mcp.Handler { |
| 274 | return func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 275 | if raw, present := call.Arguments["event" ]; present && raw != nil { |
| 276 | encoded, err := json.Marshal(raw) |
| 277 | if err != nil { |
| 278 | return mcp.Result{}, err |
| 279 | } |
| 280 | e, err := event.Parse(encoded) |
| 281 | if err == nil && check != nil { |
| 282 | err = check(e) |
| 283 | } |
| 284 | if err != nil { |
| 285 | return mcp.Failure("The event is not acceptable: " +err.Error()+"\n" +shape, map[string]any{"expected" : shape}), nil |
| 286 | } |
| 287 | return t.mcpPublish(ctx, call, e), nil |
| 288 | } |
| 289 | if build == nil { |
| 290 | return mcp.Failure("event is required. " +shape, map[string]any{"expected" : shape}), nil |
| 291 | } |
| 292 | unsigned, err := build(call) |
| 293 | if err != nil { |
| 294 | return mcp.Failure(err.Error()+"\n" +shape, map[string]any{"expected" : shape}), nil |
| 295 | } |
| 296 | unsigned, err = t.canonicalizeRoomThreadUnsigned(ctx, call.Actor, unsigned) |
| 297 | if err != nil { |
| 298 | return mcp.Failure(err.Error()+"\n" +shape, map[string]any{"expected" : shape}), nil |
| 299 | } |
| 300 | return mcp.Value(map[string]any{"unsigned" : unsigned, "next" : mcpSignNext}), nil |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // canonicalizeRoomThreadUnsigned keeps room replies at one visible level. |
| 305 | // The event is still unsigned, so its parent reference can be normalized |
| 306 | // before the client signs it. Signed events are intentionally checked as |
| 307 | // submitted because changing them would invalidate their signatures. |
| 308 | func (t *Tenant) canonicalizeRoomThreadUnsigned(ctx context.Context, actor string, unsigned mcpUnsigned) (mcpUnsigned, error) { |
| 309 | if unsigned.Kind != event.KIND_THREAD_REPLY && unsigned.Kind != event.KIND_CHAT { |
| 310 | return unsigned, nil |
| 311 | } |
| 312 | room := event.Tag(event.Event{Tags: unsigned.Tags}, "h" ) |
| 313 | rootID := event.Tag(event.Event{Tags: unsigned.Tags}, "e" ) |
| 314 | if room == "" || rootID == "" { |
| 315 | return unsigned, nil |
| 316 | } |
| 317 | root, err := t.roomThreadRoot(ctx, actor, room, rootID) |
| 318 | if err != nil { |
| 319 | return mcpUnsigned{}, err |
| 320 | } |
| 321 | for _, tag := range unsigned.Tags { |
| 322 | if len(tag) >= 2 && tag[0] == "e" { |
| 323 | tag[1] = root.ID |
| 324 | break |
| 325 | } |
| 326 | } |
| 327 | if unsigned.Kind == event.KIND_THREAD_REPLY { |
| 328 | for _, tag := range unsigned.Tags { |
| 329 | if len(tag) >= 2 && tag[0] == "p" { |
| 330 | tag[1] = root.PubKey |
| 331 | break |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | return unsigned, nil |
| 336 | } |
| 337 | |
| 338 | func (t *Tenant) mcpPublish(ctx context.Context, call mcp.Call, e event.Event) mcp.Result { |
| 339 | s := relay.Session{RelayURL: t.RelayURL(), PubKeys: []string{call.Actor}} |
| 340 | if call.Request != nil { |
| 341 | if ip, _, err := net.SplitHostPort(call.Request.RemoteAddr); err == nil { |
| 342 | s.RemoteIP = ip |
| 343 | } else { |
| 344 | s.RemoteIP = call.Request.RemoteAddr |
| 345 | } |
| 346 | } |
| 347 | message, err := t.router.Publish(ctx, e, s) |
| 348 | if err != nil { |
| 349 | return mcp.Failure("The relay rejected the event: " +err.Error(), map[string]any{"event_id" : e.ID, "accepted" : false, "message" : err.Error()}) |
| 350 | } |
| 351 | return mcp.Value(map[string]any{"event_id" : e.ID, "accepted" : true, "message" : message}) |
| 352 | } |
| 353 | |
| 354 | func mcpRepositoryTags(call mcp.Call) (string, string, [][]string, error) { |
| 355 | owner, repo := call.String("owner" ), call.String("repo" ) |
| 356 | if owner == "" || repo == "" { |
| 357 | return "" , "" , nil, errors.New("owner and repo are required" ) |
| 358 | } |
| 359 | coordinate := "30617:" + owner + ":" + repo |
| 360 | return owner, coordinate, [][]string{{"a" , coordinate}}, nil |
| 361 | } |
| 362 | |
| 363 | func mcpLabels(call mcp.Call, tags [][]string) [][]string { |
| 364 | seen := map[string]bool{} |
| 365 | if labels, ok := call.Arguments["labels" ].([]any); ok { |
| 366 | for _, label := range labels { |
| 367 | text, _ := label.(string) |
| 368 | text = strings.TrimSpace(text) |
| 369 | if text != "" && !seen[text] { |
| 370 | seen[text] = true |
| 371 | tags = append(tags, []string{"t" , text}) |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | return tags |
| 376 | } |
| 377 | |
| 378 | func mcpBuildIssue(call mcp.Call) (mcpUnsigned, error) { |
| 379 | owner, _, tags, err := mcpRepositoryTags(call) |
| 380 | if err != nil { |
| 381 | return mcpUnsigned{}, err |
| 382 | } |
| 383 | title, content := strings.TrimSpace(call.String("title" )), call.String("content" ) |
| 384 | if title == "" || strings.TrimSpace(content) == "" { |
| 385 | return mcpUnsigned{}, errors.New("title and content are required" ) |
| 386 | } |
| 387 | tags = mcpLabels(call, append(tags, []string{"p" , owner}, []string{"subject" , title})) |
| 388 | return mcpUnsigned{Kind: event.KIND_GIT_ISSUE, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 389 | } |
| 390 | |
| 391 | func mcpBuildPull(call mcp.Call) (mcpUnsigned, error) { |
| 392 | owner, _, tags, err := mcpRepositoryTags(call) |
| 393 | if err != nil { |
| 394 | return mcpUnsigned{}, err |
| 395 | } |
| 396 | title, commit, clone, base := strings.TrimSpace(call.String("title" )), call.String("commit" ), call.String("clone" ), call.String("merge_base" ) |
| 397 | if title == "" || commit == "" || clone == "" { |
| 398 | return mcpUnsigned{}, errors.New("title, commit and clone are required" ) |
| 399 | } |
| 400 | if err := mcpCloneURL(clone); err != nil { |
| 401 | return mcpUnsigned{}, err |
| 402 | } |
| 403 | tags = append(tags, []string{"p" , owner}, []string{"subject" , title}, []string{"c" , commit}, []string{"clone" , clone}) |
| 404 | if base != "" { |
| 405 | tags = append(tags, []string{"merge-base" , base}) |
| 406 | } |
| 407 | return mcpUnsigned{Kind: event.KIND_GIT_PR, CreatedAt: time.Now().Unix(), Tags: mcpLabels(call, tags), Content: call.String("content" )}, nil |
| 408 | } |
| 409 | |
| 410 | func mcpBuildComment(call mcp.Call) (mcpUnsigned, error) { |
| 411 | _, _, tags, err := mcpRepositoryTags(call) |
| 412 | if err != nil { |
| 413 | return mcpUnsigned{}, err |
| 414 | } |
| 415 | root, rootPubKey, content := call.String("root" ), call.String("root_pubkey" ), call.String("content" ) |
| 416 | rootKind := call.Int("root_kind" ) |
| 417 | if root == "" || rootPubKey == "" || rootKind == 0 || strings.TrimSpace(content) == "" { |
| 418 | return mcpUnsigned{}, errors.New("root, root_kind, root_pubkey and content are required" ) |
| 419 | } |
| 420 | parent, parentPubKey, parentKind := call.String("parent" ), call.String("parent_pubkey" ), call.Int("parent_kind" ) |
| 421 | if parent == "" { |
| 422 | parent, parentPubKey, parentKind = root, rootPubKey, rootKind |
| 423 | } |
| 424 | if parentPubKey == "" || parentKind == 0 { |
| 425 | return mcpUnsigned{}, errors.New("parent needs parent_kind and parent_pubkey" ) |
| 426 | } |
| 427 | tags = append(tags, []string{"E" , root, "" , rootPubKey}, []string{"K" , strconv.Itoa(rootKind)}, []string{"P" , rootPubKey}, []string{"e" , parent, "" , parentPubKey}, []string{"k" , strconv.Itoa(parentKind)}, []string{"p" , parentPubKey}) |
| 428 | file, line, side := call.String("file" ), call.Int("line" ), call.String("side" ) |
| 429 | if file != "" || line != 0 || side != "" { |
| 430 | if strings.TrimSpace(file) == "" || line < 1 || (side != "old" && side != "new" ) { |
| 431 | return mcpUnsigned{}, errors.New("file, line (a positive integer) and side (old or new) go together" ) |
| 432 | } |
| 433 | if rootKind != event.KIND_GIT_PR && rootKind != event.KIND_GIT_PATCH { |
| 434 | return mcpUnsigned{}, errors.New("file and line anchor a comment to a pull request or patch diff" ) |
| 435 | } |
| 436 | tags = append(tags, []string{"file" , file}, []string{"line" , strconv.Itoa(line), side}) |
| 437 | } |
| 438 | return mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 439 | } |
| 440 | |
| 441 | func mcpBuildStatus(call mcp.Call) (mcpUnsigned, error) { |
| 442 | owner, _, tags, err := mcpRepositoryTags(call) |
| 443 | if err != nil { |
| 444 | return mcpUnsigned{}, err |
| 445 | } |
| 446 | root, rootPubKey, status := call.String("root" ), call.String("root_pubkey" ), call.String("status" ) |
| 447 | kind, known := mcpStatusKinds[status] |
| 448 | if root == "" || rootPubKey == "" || !known { |
| 449 | return mcpUnsigned{}, errors.New("root, root_pubkey and status are required" ) |
| 450 | } |
| 451 | tags = append(tags, []string{"e" , root, "" , "root" }, []string{"p" , rootPubKey}) |
| 452 | if owner != rootPubKey { |
| 453 | tags = append(tags, []string{"p" , owner}) |
| 454 | } |
| 455 | return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "" }, nil |
| 456 | } |
| 457 | |
| 458 | func mcpCoordinateOwner(e event.Event) (string, error) { |
| 459 | coordinate := event.Tag(e, "a" ) |
| 460 | if !mcpCoordinate.MatchString(coordinate) { |
| 461 | return "" , errors.New(`the a tag must name the repository as 30617:<owner>:<repo>` ) |
| 462 | } |
| 463 | return strings.Split(coordinate, ":" )[1], nil |
| 464 | } |
| 465 | |
| 466 | func mcpCheckIssue(e event.Event) error { |
| 467 | if e.Kind != event.KIND_GIT_ISSUE { |
| 468 | return fmt.Errorf("kind %d is not an issue" , e.Kind) |
| 469 | } |
| 470 | return mcpCheckRoot(e) |
| 471 | } |
| 472 | |
| 473 | func mcpCheckPull(e event.Event) error { |
| 474 | if e.Kind != event.KIND_GIT_PR { |
| 475 | return fmt.Errorf("kind %d is not a pull request" , e.Kind) |
| 476 | } |
| 477 | if err := mcpCheckRoot(e); err != nil { |
| 478 | return err |
| 479 | } |
| 480 | if !hex40(event.Tag(e, "c" )) || (event.Tag(e, "merge-base" ) != "" && !hex40(event.Tag(e, "merge-base" ))) { |
| 481 | return errors.New("the c tag (and merge-base, when present) must be a full 40-hex commit ID" ) |
| 482 | } |
| 483 | return mcpCloneURL(event.Tag(e, "clone" )) |
| 484 | } |
| 485 | |
| 486 | func mcpCheckRoot(e event.Event) error { |
| 487 | owner, err := mcpCoordinateOwner(e) |
| 488 | if err != nil { |
| 489 | return err |
| 490 | } |
| 491 | if !contains(event.TagValues(e, "p" ), owner) { |
| 492 | return errors.New("a p tag must name the repository owner" ) |
| 493 | } |
| 494 | if strings.TrimSpace(event.Tag(e, "subject" )) == "" { |
| 495 | return errors.New("the subject tag carries the title and must not be empty" ) |
| 496 | } |
| 497 | if e.Kind == event.KIND_GIT_ISSUE && strings.TrimSpace(e.Content) == "" { |
| 498 | return errors.New("content must not be empty" ) |
| 499 | } |
| 500 | return nil |
| 501 | } |
| 502 | |
| 503 | func mcpCheckComment(e event.Event) error { |
| 504 | if e.Kind != 1111 { |
| 505 | return fmt.Errorf("kind %d is not a comment" , e.Kind) |
| 506 | } |
| 507 | if _, err := mcpCoordinateOwner(e); err != nil { |
| 508 | return err |
| 509 | } |
| 510 | rootKind := event.Tag(e, "K" ) |
| 511 | if !hex64(event.Tag(e, "E" )) || !hex64(event.Tag(e, "P" )) || !contains([]string{"1617" , "1618" , "1621" }, rootKind) { |
| 512 | return errors.New("the E, K and P tags must name the root event, its kind (1617, 1618 or 1621) and its author" ) |
| 513 | } |
| 514 | if !hex64(event.Tag(e, "e" )) || !hex64(event.Tag(e, "p" )) || !contains([]string{"1111" , rootKind}, event.Tag(e, "k" )) { |
| 515 | return errors.New("the e, k and p tags must name the parent event, its kind and its author" ) |
| 516 | } |
| 517 | if strings.TrimSpace(e.Content) == "" { |
| 518 | return errors.New("content must not be empty" ) |
| 519 | } |
| 520 | if err := validateReviewAnchor(e); err != nil { |
| 521 | return errors.New(strings.TrimPrefix(err.Error(), "blocked: " )) |
| 522 | } |
| 523 | return nil |
| 524 | } |
| 525 | |
| 526 | func mcpCheckStatus(e event.Event) error { |
| 527 | if e.Kind < 1630 || e.Kind > 1633 { |
| 528 | return fmt.Errorf("kind %d is not a status" , e.Kind) |
| 529 | } |
| 530 | if _, err := mcpCoordinateOwner(e); err != nil { |
| 531 | return err |
| 532 | } |
| 533 | if !hex64(event.Tag(e, "e" )) || !hex64(event.Tag(e, "p" )) { |
| 534 | return errors.New("the e and p tags must name the issue or pull request and its author" ) |
| 535 | } |
| 536 | return nil |
| 537 | } |
| 538 | |
| 539 | func mcpCloneURL(raw string) error { |
| 540 | clone, err := url.Parse(raw) |
| 541 | if err != nil || (clone.Scheme != "http" && clone.Scheme != "https" ) || clone.Host == "" || clone.User != nil || clone.Fragment != "" { |
| 542 | return errors.New("clone must be an HTTP or HTTPS URL without credentials" ) |
| 543 | } |
| 544 | return nil |
| 545 | } |
| 546 | |
| 547 | func hex64(value string) bool { return len(value) == 64 && isHexLower(value) } |
| 548 | func hex40(value string) bool { return len(value) == 40 && isHexLower(value) } |
| 549 | func isHexLower(value string) bool { |
| 550 | return value != "" && strings.Trim(value, "0123456789abcdef" ) == "" |
| 551 | } |
| 552 | |
| 553 | func merge(base, extra map[string]any) map[string]any { |
| 554 | out := make(map[string]any, len(base)+len(extra)) |
| 555 | for key, value := range base { |
| 556 | out[key] = value |
| 557 | } |
| 558 | for key, value := range extra { |
| 559 | out[key] = value |
| 560 | } |
| 561 | return out |
| 562 | } |
| 563 | |
| 564 | // Room, wiki and decision builders. Each returns the unsigned event for the |
| 565 | // caller to sign; the matching check accepts the signed event back. |
| 566 | |
| 567 | func mcpRoomTag(call mcp.Call) ([][]string, error) { |
| 568 | room := call.String("room" ) |
| 569 | if !community.ValidRoomID(room) { |
| 570 | return nil, errors.New("room is required and must be a room id" ) |
| 571 | } |
| 572 | return [][]string{{"h" , room}}, nil |
| 573 | } |
| 574 | |
| 575 | func mcpPubKeys(call mcp.Call, key string, tags [][]string) [][]string { |
| 576 | seen := map[string]bool{} |
| 577 | if values, ok := call.Arguments[key].([]any); ok { |
| 578 | for _, value := range values { |
| 579 | text, _ := value.(string) |
| 580 | if hex64(text) && !seen[text] { |
| 581 | seen[text] = true |
| 582 | tags = append(tags, []string{"p" , text}) |
| 583 | } |
| 584 | } |
| 585 | } |
| 586 | return tags |
| 587 | } |
| 588 | |
| 589 | func mcpBuildMessage(call mcp.Call) (mcpUnsigned, error) { |
| 590 | tags, err := mcpRoomTag(call) |
| 591 | if err != nil { |
| 592 | return mcpUnsigned{}, err |
| 593 | } |
| 594 | content, attachmentTags, err := mcpRoomContent(call) |
| 595 | if err != nil { |
| 596 | return mcpUnsigned{}, err |
| 597 | } |
| 598 | if strings.TrimSpace(content) == "" { |
| 599 | return mcpUnsigned{}, errors.New("content or attachments is required" ) |
| 600 | } |
| 601 | tags = append(tags, attachmentTags...) |
| 602 | return mcpUnsigned{Kind: event.KIND_CHAT, CreatedAt: time.Now().Unix(), Tags: mcpPubKeys(call, "mentions" , tags), Content: content}, nil |
| 603 | } |
| 604 | |
| 605 | func mcpBuildThread(call mcp.Call) (mcpUnsigned, error) { |
| 606 | tags, err := mcpRoomTag(call) |
| 607 | if err != nil { |
| 608 | return mcpUnsigned{}, err |
| 609 | } |
| 610 | title, content := strings.TrimSpace(call.String("title" )), call.String("content" ) |
| 611 | content, attachmentTags, err := mcpRoomContent(call) |
| 612 | if err != nil { |
| 613 | return mcpUnsigned{}, err |
| 614 | } |
| 615 | if strings.TrimSpace(content) == "" { |
| 616 | return mcpUnsigned{}, errors.New("content or attachments is required" ) |
| 617 | } |
| 618 | if title != "" { |
| 619 | tags = append(tags, []string{"subject" , title}) |
| 620 | } |
| 621 | tags = append(tags, attachmentTags...) |
| 622 | return mcpUnsigned{Kind: event.KIND_THREAD, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 623 | } |
| 624 | |
| 625 | func mcpBuildReply(call mcp.Call) (mcpUnsigned, error) { |
| 626 | tags, err := mcpRoomTag(call) |
| 627 | if err != nil { |
| 628 | return mcpUnsigned{}, err |
| 629 | } |
| 630 | root, rootPubKey := call.String("root" ), call.String("root_pubkey" ) |
| 631 | content, attachmentTags, err := mcpRoomContent(call) |
| 632 | if root == "" || strings.TrimSpace(content) == "" { |
| 633 | return mcpUnsigned{}, errors.New("root and content or attachments are required" ) |
| 634 | } |
| 635 | tags = append(tags, []string{"e" , root}) |
| 636 | if rootPubKey != "" { |
| 637 | tags = append(tags, []string{"p" , rootPubKey}) |
| 638 | } |
| 639 | tags = append(tags, attachmentTags...) |
| 640 | return mcpUnsigned{Kind: event.KIND_THREAD_REPLY, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 641 | } |
| 642 | |
| 643 | func mcpBuildReact(call mcp.Call) (mcpUnsigned, error) { |
| 644 | target, targetPubKey, content, room := call.String("target" ), call.String("target_pubkey" ), call.String("content" ), call.String("room" ) |
| 645 | if target == "" || targetPubKey == "" || content == "" { |
| 646 | return mcpUnsigned{}, errors.New("target, target_pubkey and content are required" ) |
| 647 | } |
| 648 | if !mcpReaction(content) { |
| 649 | return mcpUnsigned{}, errors.New("content must be +, - or one emoji" ) |
| 650 | } |
| 651 | tags := [][]string{{"e" , target}, {"p" , targetPubKey}} |
| 652 | if room != "" { |
| 653 | tags = append(tags, []string{"h" , room}) |
| 654 | } |
| 655 | return mcpUnsigned{Kind: 7, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 656 | } |
| 657 | |
| 658 | func mcpBuildWikiPage(call mcp.Call) (mcpUnsigned, error) { |
| 659 | d, title, content := wiki.Normalize(call.String("d" )), strings.TrimSpace(call.String("title" )), call.String("content" ) |
| 660 | if d == "" || title == "" || strings.TrimSpace(content) == "" { |
| 661 | return mcpUnsigned{}, errors.New("d, title and content are required" ) |
| 662 | } |
| 663 | tags := [][]string{{"d" , d}, {"title" , title}} |
| 664 | if summary := strings.TrimSpace(call.String("summary" )); summary != "" { |
| 665 | tags = append(tags, []string{"summary" , summary}) |
| 666 | } |
| 667 | forkAuthor, forkEvent := call.String("fork_author" ), call.String("fork_event" ) |
| 668 | if (forkAuthor == "" ) != (forkEvent == "" ) { |
| 669 | return mcpUnsigned{}, errors.New("a fork needs both fork_author and fork_event" ) |
| 670 | } |
| 671 | if forkAuthor != "" { |
| 672 | tags = append(tags, []string{"a" , "30818:" + forkAuthor + ":" + d, "" , "fork" }, []string{"e" , forkEvent, "" , "fork" }) |
| 673 | } |
| 674 | return mcpUnsigned{Kind: kindWikiArticle, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 675 | } |
| 676 | |
| 677 | func mcpBuildWikiMerge(call mcp.Call) (mcpUnsigned, error) { |
| 678 | d, destination, source, base, content := wiki.Normalize(call.String("d" )), call.String("destination" ), call.String("source" ), call.String("base" ), call.String("content" ) |
| 679 | if d == "" || destination == "" || source == "" || strings.TrimSpace(content) == "" { |
| 680 | return mcpUnsigned{}, errors.New("d, destination, source and content are required" ) |
| 681 | } |
| 682 | tags := [][]string{{"a" , "30818:" + destination + ":" + d}, {"p" , destination}, {"e" , source, "" , "source" }} |
| 683 | if base != "" { |
| 684 | tags = append(tags, []string{"e" , base}) |
| 685 | } |
| 686 | return mcpUnsigned{Kind: kindWikiMerge, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 687 | } |
| 688 | |
| 689 | // mcpBuildSite builds a site manifest for the caller's own site or a named |
| 690 | // site under its key. The template passes the same validation as the signed |
| 691 | // event so a bad path is reported before anything is signed. |
| 692 | func mcpBuildSite(call mcp.Call) (mcpUnsigned, error) { |
| 693 | kind, tags := sites.KindSite, [][]string{} |
| 694 | if label := strings.TrimSpace(call.String("label" )); label != "" { |
| 695 | site, ok := sites.ParseSite(label) |
| 696 | if !ok || site.Kind == sites.KindSiteSnapshot { |
| 697 | return mcpUnsigned{}, errors.New("label must be your npub or a named site label under your key" ) |
| 698 | } |
| 699 | if site.PubKey != call.Actor { |
| 700 | return mcpUnsigned{}, errors.New("label " + label + " is not a site under your key" ) |
| 701 | } |
| 702 | kind = site.Kind |
| 703 | if site.Kind == sites.KindNamedSite { |
| 704 | tags = append(tags, []string{"d" , site.D}) |
| 705 | } |
| 706 | } |
| 707 | pairs, _ := call.Arguments["paths" ].([]any) |
| 708 | if len(pairs) == 0 { |
| 709 | return mcpUnsigned{}, errors.New("paths is required: one [path, sha256] pair per file" ) |
| 710 | } |
| 711 | for _, pair := range pairs { |
| 712 | values, _ := pair.([]any) |
| 713 | if len(values) != 2 { |
| 714 | return mcpUnsigned{}, errors.New("each entry of paths is a [path, sha256] pair" ) |
| 715 | } |
| 716 | path, _ := values[0].(string) |
| 717 | hash, _ := values[1].(string) |
| 718 | tags = append(tags, []string{"path" , path, strings.ToLower(strings.TrimSpace(hash))}) |
| 719 | } |
| 720 | if expiration := call.Int("expiration" ); expiration > 0 { |
| 721 | if int64(expiration) <= time.Now().Unix() { |
| 722 | return mcpUnsigned{}, errors.New("expiration must be in the future" ) |
| 723 | } |
| 724 | tags = append(tags, []string{"expiration" , strconv.Itoa(expiration)}) |
| 725 | } |
| 726 | if err := sites.ValidateManifest(event.Event{Kind: kind, PubKey: call.Actor, Tags: tags}); err != nil { |
| 727 | return mcpUnsigned{}, errors.New(strings.TrimPrefix(err.Error(), "invalid: " )) |
| 728 | } |
| 729 | return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "" }, nil |
| 730 | } |
| 731 | |
| 732 | func mcpCheckSite(e event.Event) error { |
| 733 | if e.Kind != sites.KindSite && e.Kind != sites.KindNamedSite { |
| 734 | return fmt.Errorf("kind %d is not a site manifest" , e.Kind) |
| 735 | } |
| 736 | if err := sites.ValidateManifest(e); err != nil { |
| 737 | return errors.New(strings.TrimPrefix(err.Error(), "invalid: " )) |
| 738 | } |
| 739 | return nil |
| 740 | } |
| 741 | |
| 742 | func mcpBuildRoom(call mcp.Call) (mcpUnsigned, error) { |
| 743 | tags, err := mcpRoomTag(call) |
| 744 | if err != nil { |
| 745 | return mcpUnsigned{}, err |
| 746 | } |
| 747 | name := strings.TrimSpace(call.String("name" )) |
| 748 | if name == "" { |
| 749 | return mcpUnsigned{}, errors.New("name is required" ) |
| 750 | } |
| 751 | tags = append(tags, []string{"name" , name}) |
| 752 | if about := strings.TrimSpace(call.String("about" )); about != "" { |
| 753 | tags = append(tags, []string{"about" , about}) |
| 754 | } |
| 755 | if visibility := call.String("visibility" ); visibility != "" { |
| 756 | tags = append(tags, []string{"visibility" , visibility}) |
| 757 | } |
| 758 | return mcpUnsigned{Kind: event.KIND_CREATE_GROUP, CreatedAt: time.Now().Unix(), Tags: tags, Content: "" }, nil |
| 759 | } |
| 760 | |
| 761 | func mcpBuildRequest(call mcp.Call) (mcpUnsigned, error) { |
| 762 | pubkey, request, content := call.String("pubkey" ), call.String("request" ), call.String("content" ) |
| 763 | if pubkey == "" || !contains(mcpRequestKinds, request) || strings.TrimSpace(content) == "" { |
| 764 | return mcpUnsigned{}, errors.New("pubkey, request and content are required" ) |
| 765 | } |
| 766 | room, root, rootPubKey, rootKind := call.String("room" ), call.String("root" ), call.String("root_pubkey" ), call.Int("root_kind" ) |
| 767 | var kind int |
| 768 | var tags [][]string |
| 769 | switch { |
| 770 | case room != "" && root != "" : |
| 771 | kind, tags = event.KIND_CHAT, [][]string{{"h" , room}, {"e" , root, "" , "root" }} |
| 772 | case room != "" : |
| 773 | kind, tags = event.KIND_CHAT, [][]string{{"h" , room}} |
| 774 | case root != "" : |
| 775 | if rootPubKey == "" || rootKind <= 0 { |
| 776 | return mcpUnsigned{}, errors.New("root needs root_kind and root_pubkey" ) |
| 777 | } |
| 778 | k := strconv.Itoa(rootKind) |
| 779 | kind, tags = 1111, [][]string{{"E" , root, "" , rootPubKey}, {"K" , k}, {"P" , rootPubKey}, {"e" , root, "" , rootPubKey}, {"k" , k}} |
| 780 | if rootPubKey != pubkey { |
| 781 | tags = append(tags, []string{"p" , rootPubKey}) |
| 782 | } |
| 783 | default: |
| 784 | return mcpUnsigned{}, errors.New("room or root is required" ) |
| 785 | } |
| 786 | tags = append(tags, []string{"request" , request}, []string{"p" , pubkey}) |
| 787 | if expiration := call.Int("expiration" ); expiration > 0 { |
| 788 | if int64(expiration) <= time.Now().Unix() { |
| 789 | return mcpUnsigned{}, errors.New("expiration must be in the future" ) |
| 790 | } |
| 791 | tags = append(tags, []string{"expiration" , strconv.Itoa(expiration)}) |
| 792 | } |
| 793 | if subject := strings.TrimSpace(call.String("subject" )); subject != "" { |
| 794 | tags = append(tags, []string{"subject" , subject}) |
| 795 | } |
| 796 | return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 797 | } |
| 798 | |
| 799 | func mcpCheckRoomEvent(e event.Event, kind int, what string) error { |
| 800 | if e.Kind != kind { |
| 801 | return fmt.Errorf("kind %d is not a %s" , e.Kind, what) |
| 802 | } |
| 803 | if !community.ValidRoomID(event.Tag(e, "h" )) { |
| 804 | return errors.New("the h tag must name the room" ) |
| 805 | } |
| 806 | return nil |
| 807 | } |
| 808 | |
| 809 | func mcpCheckMessage(e event.Event) error { |
| 810 | if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message" ); err != nil { |
| 811 | return err |
| 812 | } |
| 813 | if strings.TrimSpace(e.Content) == "" { |
| 814 | return errors.New("content must not be empty" ) |
| 815 | } |
| 816 | return mcpCheckAttachments(e) |
| 817 | } |
| 818 | |
| 819 | func mcpCheckThread(e event.Event) error { |
| 820 | if err := mcpCheckRoomEvent(e, event.KIND_THREAD, "thread" ); err != nil { |
| 821 | return err |
| 822 | } |
| 823 | if strings.TrimSpace(e.Content) == "" { |
| 824 | return errors.New("content must not be empty" ) |
| 825 | } |
| 826 | return mcpCheckAttachments(e) |
| 827 | } |
| 828 | |
| 829 | func mcpCheckReply(e event.Event) error { |
| 830 | if err := mcpCheckRoomEvent(e, event.KIND_THREAD_REPLY, "thread reply" ); err != nil { |
| 831 | return err |
| 832 | } |
| 833 | if !hex64(event.Tag(e, "e" )) { |
| 834 | return errors.New("the e tag must name the thread root" ) |
| 835 | } |
| 836 | if strings.TrimSpace(e.Content) == "" { |
| 837 | return errors.New("content must not be empty" ) |
| 838 | } |
| 839 | return mcpCheckAttachments(e) |
| 840 | } |
| 841 | |
| 842 | func mcpCheckReact(e event.Event) error { |
| 843 | if e.Kind != 7 { |
| 844 | return fmt.Errorf("kind %d is not a reaction" , e.Kind) |
| 845 | } |
| 846 | if !hex64(event.Tag(e, "e" )) || !hex64(event.Tag(e, "p" )) { |
| 847 | return errors.New("the e and p tags must name the event and its author" ) |
| 848 | } |
| 849 | if room := event.Tag(e, "h" ); room != "" && !community.ValidRoomID(room) { |
| 850 | return errors.New("the h tag must name a room" ) |
| 851 | } |
| 852 | if !mcpReaction(e.Content) { |
| 853 | return errors.New("content must be +, - or one emoji" ) |
| 854 | } |
| 855 | return nil |
| 856 | } |
| 857 | |
| 858 | func mcpCheckWikiPage(e event.Event) error { |
| 859 | if e.Kind != kindWikiArticle { |
| 860 | return fmt.Errorf("kind %d is not a wiki page" , e.Kind) |
| 861 | } |
| 862 | d := event.Tag(e, "d" ) |
| 863 | if d == "" || d != wiki.Normalize(d) { |
| 864 | return errors.New("the d tag must carry the normalized page name" ) |
| 865 | } |
| 866 | if strings.TrimSpace(event.Tag(e, "title" )) == "" || strings.TrimSpace(e.Content) == "" { |
| 867 | return errors.New("the title tag and content must not be empty" ) |
| 868 | } |
| 869 | for _, tag := range e.Tags { |
| 870 | if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer" ) { |
| 871 | continue |
| 872 | } |
| 873 | if (tag[0] == "a" && !mcpWikiCoordinate.MatchString(tag[1])) || (tag[0] == "e" && !hex64(tag[1])) { |
| 874 | return errors.New("fork and defer tags must name a 30818:<author>:<page> coordinate or a version id" ) |
| 875 | } |
| 876 | } |
| 877 | return nil |
| 878 | } |
| 879 | |
| 880 | func mcpCheckWikiMerge(e event.Event) error { |
| 881 | if e.Kind != kindWikiMerge { |
| 882 | return fmt.Errorf("kind %d is not a merge request" , e.Kind) |
| 883 | } |
| 884 | coordinate := event.Tag(e, "a" ) |
| 885 | if !mcpWikiCoordinate.MatchString(coordinate) { |
| 886 | return errors.New("the a tag must name the target as 30818:<destination>:<page>" ) |
| 887 | } |
| 888 | if destination := event.Tag(e, "p" ); !hex64(destination) || destination != strings.Split(coordinate, ":" )[1] { |
| 889 | return errors.New("the p tag must name the destination author of the a tag" ) |
| 890 | } |
| 891 | source := "" |
| 892 | for _, tag := range e.Tags { |
| 893 | if len(tag) >= 4 && tag[0] == "e" && tag[3] == "source" { |
| 894 | source = tag[1] |
| 895 | } |
| 896 | } |
| 897 | if !hex64(source) { |
| 898 | return errors.New("an e tag with the source marker must name the proposed version" ) |
| 899 | } |
| 900 | if strings.TrimSpace(e.Content) == "" { |
| 901 | return errors.New("content must not be empty" ) |
| 902 | } |
| 903 | return nil |
| 904 | } |
| 905 | |
| 906 | func mcpCheckRoom(e event.Event) error { |
| 907 | if err := mcpCheckRoomEvent(e, event.KIND_CREATE_GROUP, "room creation" ); err != nil { |
| 908 | return err |
| 909 | } |
| 910 | if strings.TrimSpace(event.Tag(e, "name" )) == "" { |
| 911 | return errors.New("the name tag must not be empty" ) |
| 912 | } |
| 913 | if visibility := event.Tag(e, "visibility" ); visibility != "" && visibility != community.RoomOpen && visibility != community.RoomMembers { |
| 914 | return errors.New("the visibility tag must be open or members" ) |
| 915 | } |
| 916 | return nil |
| 917 | } |
| 918 | |
| 919 | func mcpCheckRequest(e event.Event) error { |
| 920 | switch e.Kind { |
| 921 | case event.KIND_CHAT: |
| 922 | if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message" ); err != nil { |
| 923 | return err |
| 924 | } |
| 925 | case 1111: |
| 926 | if !hex64(event.Tag(e, "E" )) || !hex64(event.Tag(e, "P" )) || event.Tag(e, "K" ) == "" || !hex64(event.Tag(e, "e" )) || event.Tag(e, "k" ) == "" { |
| 927 | return errors.New("the E, K, P, e and k tags must name the root event, its kind and its author" ) |
| 928 | } |
| 929 | default: |
| 930 | return fmt.Errorf("kind %d is not a chat message or comment" , e.Kind) |
| 931 | } |
| 932 | if !contains(mcpRequestKinds, event.Tag(e, "request" )) { |
| 933 | return errors.New("the request tag must be approve, decide or question" ) |
| 934 | } |
| 935 | asked := event.TagValues(e, "p" ) |
| 936 | if len(asked) == 0 || !hex64(asked[len(asked)-1]) { |
| 937 | return errors.New("a p tag must name the person asked" ) |
| 938 | } |
| 939 | if expiration := event.Tag(e, "expiration" ); expiration != "" { |
| 940 | if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() { |
| 941 | return errors.New("the expiration tag must be a Unix time in the future" ) |
| 942 | } |
| 943 | } |
| 944 | if strings.TrimSpace(e.Content) == "" { |
| 945 | return errors.New("content must not be empty" ) |
| 946 | } |
| 947 | return nil |
| 948 | } |
| 949 | |
| 950 | // Long task builders. A request carries its inputs and terms as NIP-90 |
| 951 | // tags; feedback and results name the request and the requester. |
| 952 | |
| 953 | func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) { |
| 954 | kind := call.Int("kind" ) |
| 955 | if !event.IsJobRequest(kind) { |
| 956 | return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999" ) |
| 957 | } |
| 958 | tags := [][]string{} |
| 959 | if room := strings.TrimSpace(call.String("room" )); room != "" { |
| 960 | if !community.ValidRoomID(room) { |
| 961 | return mcpUnsigned{}, errors.New("room must be a room id" ) |
| 962 | } |
| 963 | tags = append(tags, []string{"h" , room}) |
| 964 | } |
| 965 | if inputs, ok := call.Arguments["inputs" ].([]any); ok { |
| 966 | for _, raw := range inputs { |
| 967 | input, _ := raw.(map[string]any) |
| 968 | data, _ := input["data" ].(string) |
| 969 | kind, _ := input["type" ].(string) |
| 970 | relayHint, _ := input["relay" ].(string) |
| 971 | marker, _ := input["marker" ].(string) |
| 972 | if data == "" || !event.IsJobInputType(kind) { |
| 973 | return mcpUnsigned{}, errors.New("each input needs data and a type of url, event, job or text" ) |
| 974 | } |
| 975 | if (kind == "event" || kind == "job" ) && !hex64(data) { |
| 976 | return mcpUnsigned{}, errors.New("an input of type event or job must name an event id" ) |
| 977 | } |
| 978 | tag := []string{"i" , data, kind} |
| 979 | if relayHint != "" || marker != "" { |
| 980 | tag = append(tag, relayHint) |
| 981 | } |
| 982 | if marker != "" { |
| 983 | tag = append(tag, marker) |
| 984 | } |
| 985 | tags = append(tags, tag) |
| 986 | } |
| 987 | } |
| 988 | if output := strings.TrimSpace(call.String("output" )); output != "" { |
| 989 | tags = append(tags, []string{"output" , output}) |
| 990 | } |
| 991 | if params, ok := call.Arguments["params" ].(map[string]any); ok { |
| 992 | keys := make([]string, 0, len(params)) |
| 993 | for key := range params { |
| 994 | keys = append(keys, key) |
| 995 | } |
| 996 | sort.Strings(keys) |
| 997 | for _, key := range keys { |
| 998 | value, ok := params[key].(string) |
| 999 | if key == "" || !ok { |
| 1000 | return mcpUnsigned{}, errors.New("params must map names to string values" ) |
| 1001 | } |
| 1002 | tags = append(tags, []string{"param" , key, value}) |
| 1003 | } |
| 1004 | } |
| 1005 | if _, present := call.Arguments["bid" ]; present { |
| 1006 | bid := call.Int("bid" ) |
| 1007 | if bid < 0 { |
| 1008 | return mcpUnsigned{}, errors.New("bid must be an amount in millisats" ) |
| 1009 | } |
| 1010 | tags = append(tags, []string{"bid" , strconv.Itoa(bid)}) |
| 1011 | } |
| 1012 | if relays, ok := call.Arguments["relays" ].([]any); ok { |
| 1013 | tag := []string{"relays" } |
| 1014 | for _, value := range relays { |
| 1015 | if text, _ := value.(string); strings.TrimSpace(text) != "" { |
| 1016 | tag = append(tag, strings.TrimSpace(text)) |
| 1017 | } |
| 1018 | } |
| 1019 | if len(tag) > 1 { |
| 1020 | tags = append(tags, tag) |
| 1021 | } |
| 1022 | } |
| 1023 | if expiration := call.Int("expiration" ); expiration > 0 { |
| 1024 | if int64(expiration) <= time.Now().Unix() { |
| 1025 | return mcpUnsigned{}, errors.New("expiration must be in the future" ) |
| 1026 | } |
| 1027 | tags = append(tags, []string{"expiration" , strconv.Itoa(expiration)}) |
| 1028 | } |
| 1029 | return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: "" }, nil |
| 1030 | } |
| 1031 | |
| 1032 | // mcpJobAmount appends the amount tag a provider asks to be paid, with its |
| 1033 | // invoice when one is given. |
| 1034 | func mcpJobAmount(call mcp.Call, tags [][]string) ([][]string, error) { |
| 1035 | if _, present := call.Arguments["amount" ]; !present { |
| 1036 | return tags, nil |
| 1037 | } |
| 1038 | amount := call.Int("amount" ) |
| 1039 | if amount < 0 { |
| 1040 | return nil, errors.New("amount must be in millisats" ) |
| 1041 | } |
| 1042 | tag := []string{"amount" , strconv.Itoa(amount)} |
| 1043 | if invoice := strings.TrimSpace(call.String("invoice" )); invoice != "" { |
| 1044 | tag = append(tag, invoice) |
| 1045 | } |
| 1046 | return append(tags, tag), nil |
| 1047 | } |
| 1048 | |
| 1049 | func mcpBuildJobFeedback(call mcp.Call) (mcpUnsigned, error) { |
| 1050 | request, requester, status := call.String("e" ), call.String("p" ), call.String("status" ) |
| 1051 | if !hex64(request) || !hex64(requester) || !event.IsJobFeedbackStatus(status) { |
| 1052 | return mcpUnsigned{}, errors.New("e, p and status are required; status must be payment-required, processing, error, success or partial" ) |
| 1053 | } |
| 1054 | statusTag := []string{"status" , status} |
| 1055 | if info := strings.TrimSpace(call.String("info" )); info != "" { |
| 1056 | statusTag = append(statusTag, info) |
| 1057 | } |
| 1058 | tags := [][]string{statusTag, {"e" , request}, {"p" , requester}} |
| 1059 | if room := strings.TrimSpace(call.String("room" )); room != "" { |
| 1060 | if !community.ValidRoomID(room) { |
| 1061 | return mcpUnsigned{}, errors.New("room must be a room id" ) |
| 1062 | } |
| 1063 | tags = append(tags, []string{"h" , room}) |
| 1064 | } |
| 1065 | tags, err := mcpJobAmount(call, tags) |
| 1066 | if err != nil { |
| 1067 | return mcpUnsigned{}, err |
| 1068 | } |
| 1069 | return mcpUnsigned{Kind: event.KIND_JOB_FEEDBACK, CreatedAt: time.Now().Unix(), Tags: tags, Content: call.String("content" )}, nil |
| 1070 | } |
| 1071 | |
| 1072 | func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) { |
| 1073 | var request event.Event |
| 1074 | hasRequest := false |
| 1075 | if raw, ok := call.Arguments["request" ].(map[string]any); ok { |
| 1076 | encoded, err := json.Marshal(raw) |
| 1077 | if err == nil { |
| 1078 | err = json.Unmarshal(encoded, &request) |
| 1079 | } |
| 1080 | if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) { |
| 1081 | return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5127 or 5129 to 5999" ) |
| 1082 | } |
| 1083 | hasRequest = true |
| 1084 | } |
| 1085 | kind, id, requester, content := call.Int("kind" ), call.String("e" ), call.String("p" ), call.String("content" ) |
| 1086 | if hasRequest { |
| 1087 | if kind == 0 { |
| 1088 | kind = event.JobResultKind(request.Kind) |
| 1089 | } |
| 1090 | if id == "" { |
| 1091 | id = request.ID |
| 1092 | } |
| 1093 | if requester == "" { |
| 1094 | requester = request.PubKey |
| 1095 | } |
| 1096 | if kind != event.JobResultKind(request.Kind) || id != request.ID || requester != request.PubKey { |
| 1097 | return mcpUnsigned{}, errors.New("kind, e and p must match the request: its kind plus 1000, its id and its author" ) |
| 1098 | } |
| 1099 | } |
| 1100 | if !event.IsJobResult(kind) || !hex64(id) || !hex64(requester) { |
| 1101 | return mcpUnsigned{}, errors.New("request, or kind (the request kind plus 1000), e and p, are required" ) |
| 1102 | } |
| 1103 | if strings.TrimSpace(content) == "" { |
| 1104 | return mcpUnsigned{}, errors.New("content is required" ) |
| 1105 | } |
| 1106 | tags := [][]string{} |
| 1107 | if hasRequest { |
| 1108 | if room := event.Tag(request, "h" ); room != "" { |
| 1109 | tags = append(tags, []string{"h" , room}) |
| 1110 | } |
| 1111 | canonical, err := event.Canonical(request) |
| 1112 | if err != nil { |
| 1113 | return mcpUnsigned{}, err |
| 1114 | } |
| 1115 | tags = append(tags, []string{"request" , string(canonical)}) |
| 1116 | } |
| 1117 | if room := strings.TrimSpace(call.String("room" )); room != "" { |
| 1118 | if !community.ValidRoomID(room) { |
| 1119 | return mcpUnsigned{}, errors.New("room must be a room id" ) |
| 1120 | } |
| 1121 | if hasRequest && event.Tag(request, "h" ) != room { |
| 1122 | return mcpUnsigned{}, errors.New("room must match the request" ) |
| 1123 | } |
| 1124 | if !hasRequest { |
| 1125 | tags = append(tags, []string{"h" , room}) |
| 1126 | } |
| 1127 | } |
| 1128 | tags = append(tags, []string{"e" , id}) |
| 1129 | if hasRequest { |
| 1130 | for _, tag := range request.Tags { |
| 1131 | if len(tag) > 1 && tag[0] == "i" { |
| 1132 | tags = append(tags, append([]string(nil), tag...)) |
| 1133 | } |
| 1134 | } |
| 1135 | } |
| 1136 | tags, err := mcpJobAmount(call, append(tags, []string{"p" , requester})) |
| 1137 | if err != nil { |
| 1138 | return mcpUnsigned{}, err |
| 1139 | } |
| 1140 | return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil |
| 1141 | } |
| 1142 | |
| 1143 | // mcpJobShape reports the gate's shape check without its NIP-01 prefix, so |
| 1144 | // the tool's message reads as one sentence. |
| 1145 | func mcpJobShape(e event.Event) error { |
| 1146 | if err := gates.JobShape(e); err != nil { |
| 1147 | return errors.New(strings.TrimPrefix(err.Error(), "invalid: " )) |
| 1148 | } |
| 1149 | return nil |
| 1150 | } |
| 1151 | |
| 1152 | func mcpCheckJobRequest(e event.Event) error { |
| 1153 | if !event.IsJobRequest(e.Kind) { |
| 1154 | return fmt.Errorf("kind %d is not a job request" , e.Kind) |
| 1155 | } |
| 1156 | if expiration := event.Tag(e, "expiration" ); expiration != "" { |
| 1157 | if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() { |
| 1158 | return errors.New("the expiration tag must be a Unix time in the future" ) |
| 1159 | } |
| 1160 | } |
| 1161 | return mcpJobShape(e) |
| 1162 | } |
| 1163 | |
| 1164 | func mcpCheckJobFeedback(e event.Event) error { |
| 1165 | if e.Kind != event.KIND_JOB_FEEDBACK { |
| 1166 | return fmt.Errorf("kind %d is not job feedback" , e.Kind) |
| 1167 | } |
| 1168 | return mcpJobShape(e) |
| 1169 | } |
| 1170 | |
| 1171 | func mcpCheckJobResult(e event.Event) error { |
| 1172 | if !event.IsJobResult(e.Kind) { |
| 1173 | return fmt.Errorf("kind %d is not a job result" , e.Kind) |
| 1174 | } |
| 1175 | if request := event.Tag(e, "request" ); request != "" && (!json.Valid([]byte(request)) || !strings.HasPrefix(strings.TrimSpace(request), "{" )) { |
| 1176 | return errors.New("the request tag must carry the job request event as JSON" ) |
| 1177 | } |
| 1178 | if strings.TrimSpace(e.Content) == "" { |
| 1179 | return errors.New("content must not be empty" ) |
| 1180 | } |
| 1181 | return mcpJobShape(e) |
| 1182 | } |
| 1183 | |
| 1184 | // mcpReaction accepts NIP-25 content: +, - or one emoji, which may be a |
| 1185 | // sequence of a few non-ASCII code points such as a flag or a skin tone. |
| 1186 | func mcpReaction(content string) bool { |
| 1187 | if content == "+" || content == "-" { |
| 1188 | return true |
| 1189 | } |
| 1190 | runes := []rune(content) |
| 1191 | if len(runes) == 0 || len(runes) > 10 { |
| 1192 | return false |
| 1193 | } |
| 1194 | for _, r := range runes { |
| 1195 | if r < 0x80 || unicode.IsSpace(r) || unicode.IsLetter(r) || unicode.IsDigit(r) { |
| 1196 | return false |
| 1197 | } |
| 1198 | } |
| 1199 | return true |
| 1200 | } |