tinyrelayfile
tinyrelay/internal/daemon/mcp_tools.go/

internal/daemon/mcp_tools.go raw
1package daemon
2
3import (
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
32var (
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
61const (
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.
84type 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
91func (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
240func (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
254func (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.
273func (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.
308func (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
338func (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
354func 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
363func 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
378func 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
391func 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
410func 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
441func 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
458func 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
466func 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
473func 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
486func 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
503func 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
526func 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
539func 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
547func hex64(value string) bool { return len(value) == 64 && isHexLower(value) }
548func hex40(value string) bool { return len(value) == 40 && isHexLower(value) }
549func isHexLower(value string) bool {
550 return value != "" && strings.Trim(value, "0123456789abcdef") == ""
551}
552
553func 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
567func 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
575func 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
589func 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
605func 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
625func 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
643func 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
658func 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
677func 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.
692func 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
732func 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
742func 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
761func 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
799func 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
809func 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
819func 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
829func 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
842func 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
858func 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
880func 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
906func 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
919func 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
953func 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.
1034func 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
1049func 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
1072func 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.
1145func 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
1152func 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
1164func 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
1171func 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.
1186func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs