clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / internal / daemon / mcp_tools.go
mcp_tools.go 72.4K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "net"9 "net/url"10 "regexp"11 "strconv"12 "strings"13 "time"14 "unicode"1516 "github.com/FelineStateMachine/tinyrelay/internal/community"17 "github.com/FelineStateMachine/tinyrelay/internal/event"18 "github.com/FelineStateMachine/tinyrelay/internal/gates"19 "github.com/FelineStateMachine/tinyrelay/internal/mcp"20 "github.com/FelineStateMachine/tinyrelay/internal/relay"21 "github.com/FelineStateMachine/tinyrelay/internal/sites"22 "github.com/FelineStateMachine/tinyrelay/internal/wiki"23)2425// The MCP tool table mirrors the browser tools in tinyclient/webmcp.js.26// Reads run through the browse methods, controls through the NIP-8627// management path, and writes through the same publish path as POST /events,28// so every tool is executed as the signed-in pubkey with the tenant's29// permission checks.3031var (32 mcpText = map[string]any{"type": "string"}33 mcpPubKey = map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hex public key."}34 mcpHash = map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$"}35 mcpID = map[string]any{"type": "string", "minLength": 1}36 mcpLimit = map[string]any{"type": "integer", "minimum": 1, "maximum": 100}37 mcpCommit = map[string]any{"type": "string", "pattern": "^[0-9a-f]{40}$", "description": "Full Git commit ID."}38 mcpEvent = map[string]any{"type": "object", "description": "A signed Nostr event: id, pubkey, created_at, kind, tags, content and sig."}39 mcpRootKind = map[string]any{"type": "integer", "enum": []int{1617, 1618, 1621}, "description": "Kind of the conversation root: 1621 issue, 1618 pull request, 1617 patch."}40 mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}41 mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}42 mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}43 mcpJobEvent = map[string]any{"type": "object", "description": "The kind 43001 job request event being answered; supplies e, p and room."}44 mcpAssignees = map[string]any{"type": "array", "items": mcpPubKey, "minItems": 1, "description": "Keys asked to do the work, each becoming a p tag."}45 mcpArtifacts = map[string]any{"type": "array", "items": mcp.Object(map[string]any{"type": map[string]any{"type": "string", "enum": []string{"e", "a", "r"}}, "value": map[string]any{"type": "string", "minLength": 1}}, "type", "value"), "description": "Artifacts the result references, each becoming a tag: e an event id, a an addressable event coordinate, r an http or https URL."}46 mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false}47 mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false}48 mcpControls = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}49 mcpSettings = map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false}50 mcpPublishes = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}5152 mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)53 mcpWikiCoordinate = regexp.MustCompile(`^30818:[0-9a-f]{64}:.+$`)54 mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}55 mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}56 mcpRequestKinds = []string{"approve", "decide", "question"}57)5859const (60 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.`61 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.`62 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.`63 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>"].`64 mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.`65 mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.`66 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.`67 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.`68 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.`69 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.`70 mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`71 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.`72 mcpJobRequestShape = `Expected a signed kind 43001 event with tags ["h","<room id>"], one or more ["p","<asked pubkey>"], optional ["subject","<title>"], ["e","<thread root id>","","root"] and ["expiration","<unix time>"], with the task text in content.`73 mcpJobAnswerShape = `Expected a signed kind 43002 (accepted), 43003 (progress), 43004 (result) or 43006 (error) event with tags ["e","<request id>"], ["p","<requester pubkey>"] and ["h","<room id>"] matching the request, with the progress line, output or error message in content. A result may add ["e","<event id>"], ["a","<coordinate>"] and ["r","<http or https URL>"] tags for the artifacts it produced.`74 mcpJobCancelShape = `Expected a signed kind 43005 event from the requester with tags ["e","<request id>"], ["h","<room id>"] and optional ["p","<asked pubkey>"].`75 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.`76 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."77 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."78)7980// mcpUnsigned is the event body a client signs before calling a write tool81// again.82type mcpUnsigned struct {83 Kind int `json:"kind"`84 CreatedAt int64 `json:"created_at"`85 Tags [][]string `json:"tags"`86 Content string `json:"content"`87}8889func (t *Tenant) mcpTools() (*mcp.Registry, error) {90 registry := &mcp.Registry{}91 var err error92 add := func(name, description string, schema, annotations map[string]any, handler mcp.Handler) {93 if err == nil {94 err = registry.Add(mcp.Tool{Name: name, Description: description, InputSchema: schema, Annotations: annotations, Handler: handler})95 }96 }97 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"}}}98 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")99 collaborationDetail := mcp.Object(map[string]any{"owner": mcpPubKey, "repo": mcpID, "event": mcpHash, "cursor": mcpText, "limit": mcpLimit}, "owner", "repo", "event")100101 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"))102 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"))103 add("list_issues", "List repository issues with search, status filters and pagination.", collaborationList, mcpReads, t.mcpBrowse("browseissues"))104 add("read_issue", "Read an issue, its replies and authorized status changes.", collaborationDetail, mcpReads, t.mcpBrowse("browseissue"))105 add("list_pull_requests", "List repository pull requests with search, status filters and pagination.", collaborationList, mcpReads, t.mcpBrowse("browsepulls"))106 add("read_pull_request", "Read a pull request, its replies, authorized updates and available diff.", collaborationDetail, mcpReads, t.mcpBrowse("browsepull"))107 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"))108 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"))109 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)110 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)111 add("read_status", "Read service health, storage and job status. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, t.mcpBrowse("browsestatus"))112 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) {113 method := call.String("method")114 if method == "gitstorage" {115 if call.String("owner") == "" || call.String("repo") == "" {116 return mcp.Failure("gitstorage needs owner and repo.", nil), nil117 }118 return t.mcpManage(ctx, call, method, call.String("owner"), call.String("repo"))119 }120 return t.mcpManage(ctx, call, method)121 })122 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"))123 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"))124 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"))125 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"))126 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"))127 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"))128 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) {129 return t.mcpManage(ctx, call, "listagents")130 })131 add("list_jobs", "List long task requests (kind 43001) visible to your key, each with its room, requester, assignees, subject, state (open, done, failed or cancelled), status (queued, accepted, running, done, failed or cancelled), newest progress, result with its artifacts, error and cancel time. state narrows the list; 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"))132 add("read_job", "Read one long task request by event id with its settled state and its answers oldest first: accepted, progress, result, cancel and error events.", mcp.Object(map[string]any{"id": mcpHash}, "id"), mcpReads, t.mcpBrowse("browsejob"))133 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) {134 return t.mcpManage(ctx, call, "listcallbacks")135 })136 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) {137 return t.mcpManage(ctx, call, "listcustomviews")138 })139 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) {140 return t.mcpManage(ctx, call, "listjoinrequests")141 })142143 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) {144 return t.mcpManage(ctx, call, "runjob", call.String("id"))145 })146 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) {147 return t.mcpManage(ctx, call, "addjob", call.Arguments)148 })149 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) {150 return t.mcpManage(ctx, call, "removejob", call.String("id"))151 })152 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) {153 return t.mcpManage(ctx, call, "backupnow")154 })155 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) {156 return t.mcpManage(ctx, call, "dumpnow")157 })158 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) {159 return t.mcpManage(ctx, call, "setpolicy", call.Arguments["patch"])160 })161 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) {162 return t.mcpManage(ctx, call, "setconnections", call.Arguments["connections"])163 })164 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) {165 return t.mcpManage(ctx, call, "notifytest")166 })167 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) {168 return t.mcpManage(ctx, call, "addcallback", call.Arguments)169 })170 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) {171 return t.mcpManage(ctx, call, "addcustomview", call.Arguments)172 })173 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) {174 return t.mcpManage(ctx, call, "removecustomview", call.String("name"))175 })176 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) {177 return t.mcpManage(ctx, call, "pausecustomview", call.String("name"))178 })179 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) {180 return t.mcpManage(ctx, call, "resumecustomview", call.String("name"))181 })182 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) {183 return t.mcpManage(ctx, call, "runcustomview", call.Arguments)184 })185 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) {186 return t.mcpManage(ctx, call, "removecallback", call.String("id"))187 })188 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) {189 return t.mcpManage(ctx, call, "pausecallback", call.String("id"))190 })191 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) {192 return t.mcpManage(ctx, call, "resumecallback", call.String("id"))193 })194 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) {195 return t.mcpManage(ctx, call, "pauseagent", call.String("agent"))196 })197 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) {198 return t.mcpManage(ctx, call, "resumeagent", call.String("agent"))199 })200 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) {201 return t.mcpManage(ctx, call, "revokeagent", call.String("agent"))202 })203 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) {204 return t.mcpManage(ctx, call, "approvejoin", call.String("pubkey"))205 })206 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) {207 return t.mcpManage(ctx, call, "denyjoin", call.String("pubkey"))208 })209 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) {210 return t.mcpManage(ctx, call, "pauseallagents")211 })212 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) {213 return t.mcpManage(ctx, call, "resumeallagents")214 })215216 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."))217 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))218 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))219 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))220 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))221 attachments := map[string]any{"type": "array", "items": mcpAttachment, "maxItems": 8}222 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))223 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))224 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))225 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))226 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))227 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))228 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))229 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))230 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))231 add("request_job", "Ask one or more keys to take on a long task with a kind 43001 job request in a room. Pass room, assignees and content or subject, plus optional root (a thread root in the room) and expiration. The keys asked answer with accept_job, job_progress and job_result or job_error; cancel_job withdraws the request; read_job follows it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "assignees": mcpAssignees, "subject": mcpText, "content": mcpText, "root": mcpHash, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))232 add("accept_job", "Take on a long task you were asked to do with a kind 43002 job accepted event. Pass request (the job request event) or e, p and room, plus optional content.", mcpJobAnswerSchema(false), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_ACCEPTED), mcpCheckJobAnswer(event.KIND_JOB_ACCEPTED), mcpJobAnswerShape))233 add("job_progress", "Report progress on a long task you were asked to do with a kind 43003 job progress event, as often as needed. Pass request or e, p and room, plus content with the progress line.", mcpJobAnswerSchema(false), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_PROGRESS), mcpCheckJobAnswer(event.KIND_JOB_PROGRESS), mcpJobAnswerShape))234 add("job_result", "Finish a long task with a kind 43004 job result. Pass request or e, p and room, content with the output and optional artifacts, each an e, a or r reference that clients link.", mcpJobAnswerSchema(true), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_RESULT), mcpCheckJobAnswer(event.KIND_JOB_RESULT), mcpJobAnswerShape))235 add("job_error", "End a long task that failed with a kind 43006 job error. Pass request or e, p and room, plus content with the error message.", mcpJobAnswerSchema(false), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_ERROR), mcpCheckJobAnswer(event.KIND_JOB_ERROR), mcpJobAnswerShape))236 add("cancel_job", "Cancel a long task you requested with a kind 43005 job cancel. Pass request or e and room, plus optional p (one of the keys asked) and content.", mcp.Object(map[string]any{"event": mcpEvent, "request": mcpJobEvent, "e": mcpHash, "room": mcpRoomID, "p": mcpPubKey, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobCancel, mcpCheckJobCancel, mcpJobCancelShape))237 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)238 return registry, err239}240241func (t *Tenant) mcpBrowse(method string) mcp.Handler {242 return func(ctx context.Context, call mcp.Call) (mcp.Result, error) {243 raw, err := json.Marshal(call.Arguments)244 if err != nil {245 return mcp.Result{}, err246 }247 result, err := t.Execute(ctx, call.Actor, method, []json.RawMessage{raw})248 if err != nil {249 return mcp.Result{}, err250 }251 return mcp.Value(result), nil252 }253}254255func (t *Tenant) mcpManage(ctx context.Context, call mcp.Call, method string, params ...any) (mcp.Result, error) {256 raw := make([]json.RawMessage, 0, len(params))257 for _, param := range params {258 encoded, err := json.Marshal(param)259 if err != nil {260 return mcp.Result{}, err261 }262 raw = append(raw, encoded)263 }264 result, err := t.Execute(ctx, call.Actor, method, raw)265 if err != nil {266 return mcp.Result{}, err267 }268 return mcp.Value(map[string]any{"result": result}), nil269}270271// mcpWrite serves a write tool. A signed event argument is checked against272// the expected shape and published; without one the builder returns the273// unsigned event for the client to sign.274func (t *Tenant) mcpWrite(build func(mcp.Call) (mcpUnsigned, error), check func(event.Event) error, shape string) mcp.Handler {275 return func(ctx context.Context, call mcp.Call) (mcp.Result, error) {276 if raw, present := call.Arguments["event"]; present && raw != nil {277 encoded, err := json.Marshal(raw)278 if err != nil {279 return mcp.Result{}, err280 }281 e, err := event.Parse(encoded)282 if err == nil && check != nil {283 err = check(e)284 }285 if err != nil {286 return mcp.Failure("The event is not acceptable: "+err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil287 }288 return t.mcpPublish(ctx, call, e), nil289 }290 if build == nil {291 return mcp.Failure("event is required. "+shape, map[string]any{"expected": shape}), nil292 }293 unsigned, err := build(call)294 if err != nil {295 return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil296 }297 unsigned, err = t.canonicalizeRoomThreadUnsigned(ctx, call.Actor, unsigned)298 if err != nil {299 return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil300 }301 return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil302 }303}304305// canonicalizeRoomThreadUnsigned keeps room replies at one visible level.306// The event is still unsigned, so its parent reference can be normalized307// before the client signs it. Signed events are intentionally checked as308// submitted because changing them would invalidate their signatures.309func (t *Tenant) canonicalizeRoomThreadUnsigned(ctx context.Context, actor string, unsigned mcpUnsigned) (mcpUnsigned, error) {310 if unsigned.Kind != event.KIND_THREAD_REPLY && unsigned.Kind != event.KIND_CHAT {311 return unsigned, nil312 }313 room := event.Tag(event.Event{Tags: unsigned.Tags}, "h")314 rootID := event.Tag(event.Event{Tags: unsigned.Tags}, "e")315 if room == "" || rootID == "" {316 return unsigned, nil317 }318 root, err := t.roomThreadRoot(ctx, actor, room, rootID)319 if err != nil {320 return mcpUnsigned{}, err321 }322 for _, tag := range unsigned.Tags {323 if len(tag) >= 2 && tag[0] == "e" {324 tag[1] = root.ID325 break326 }327 }328 if unsigned.Kind == event.KIND_THREAD_REPLY {329 for _, tag := range unsigned.Tags {330 if len(tag) >= 2 && tag[0] == "p" {331 tag[1] = root.PubKey332 break333 }334 }335 }336 return unsigned, nil337}338339func (t *Tenant) mcpPublish(ctx context.Context, call mcp.Call, e event.Event) mcp.Result {340 s := relay.Session{RelayURL: t.RelayURL(), PubKeys: []string{call.Actor}}341 if call.Request != nil {342 if ip, _, err := net.SplitHostPort(call.Request.RemoteAddr); err == nil {343 s.RemoteIP = ip344 } else {345 s.RemoteIP = call.Request.RemoteAddr346 }347 }348 message, err := t.router.Publish(ctx, e, s)349 if err != nil {350 return mcp.Failure("The relay rejected the event: "+err.Error(), map[string]any{"event_id": e.ID, "accepted": false, "message": err.Error()})351 }352 return mcp.Value(map[string]any{"event_id": e.ID, "accepted": true, "message": message})353}354355func mcpRepositoryTags(call mcp.Call) (string, string, [][]string, error) {356 owner, repo := call.String("owner"), call.String("repo")357 if owner == "" || repo == "" {358 return "", "", nil, errors.New("owner and repo are required")359 }360 coordinate := "30617:" + owner + ":" + repo361 return owner, coordinate, [][]string{{"a", coordinate}}, nil362}363364func mcpLabels(call mcp.Call, tags [][]string) [][]string {365 seen := map[string]bool{}366 if labels, ok := call.Arguments["labels"].([]any); ok {367 for _, label := range labels {368 text, _ := label.(string)369 text = strings.TrimSpace(text)370 if text != "" && !seen[text] {371 seen[text] = true372 tags = append(tags, []string{"t", text})373 }374 }375 }376 return tags377}378379func mcpBuildIssue(call mcp.Call) (mcpUnsigned, error) {380 owner, _, tags, err := mcpRepositoryTags(call)381 if err != nil {382 return mcpUnsigned{}, err383 }384 title, content := strings.TrimSpace(call.String("title")), call.String("content")385 if title == "" || strings.TrimSpace(content) == "" {386 return mcpUnsigned{}, errors.New("title and content are required")387 }388 tags = mcpLabels(call, append(tags, []string{"p", owner}, []string{"subject", title}))389 return mcpUnsigned{Kind: event.KIND_GIT_ISSUE, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil390}391392func mcpBuildPull(call mcp.Call) (mcpUnsigned, error) {393 owner, _, tags, err := mcpRepositoryTags(call)394 if err != nil {395 return mcpUnsigned{}, err396 }397 title, commit, clone, base := strings.TrimSpace(call.String("title")), call.String("commit"), call.String("clone"), call.String("merge_base")398 if title == "" || commit == "" || clone == "" {399 return mcpUnsigned{}, errors.New("title, commit and clone are required")400 }401 if err := mcpCloneURL(clone); err != nil {402 return mcpUnsigned{}, err403 }404 tags = append(tags, []string{"p", owner}, []string{"subject", title}, []string{"c", commit}, []string{"clone", clone})405 if base != "" {406 tags = append(tags, []string{"merge-base", base})407 }408 return mcpUnsigned{Kind: event.KIND_GIT_PR, CreatedAt: time.Now().Unix(), Tags: mcpLabels(call, tags), Content: call.String("content")}, nil409}410411func mcpBuildComment(call mcp.Call) (mcpUnsigned, error) {412 _, _, tags, err := mcpRepositoryTags(call)413 if err != nil {414 return mcpUnsigned{}, err415 }416 root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")417 rootKind := call.Int("root_kind")418 if root == "" || rootPubKey == "" || rootKind == 0 || strings.TrimSpace(content) == "" {419 return mcpUnsigned{}, errors.New("root, root_kind, root_pubkey and content are required")420 }421 parent, parentPubKey, parentKind := call.String("parent"), call.String("parent_pubkey"), call.Int("parent_kind")422 if parent == "" {423 parent, parentPubKey, parentKind = root, rootPubKey, rootKind424 }425 if parentPubKey == "" || parentKind == 0 {426 return mcpUnsigned{}, errors.New("parent needs parent_kind and parent_pubkey")427 }428 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})429 file, line, side := call.String("file"), call.Int("line"), call.String("side")430 if file != "" || line != 0 || side != "" {431 if strings.TrimSpace(file) == "" || line < 1 || (side != "old" && side != "new") {432 return mcpUnsigned{}, errors.New("file, line (a positive integer) and side (old or new) go together")433 }434 if rootKind != event.KIND_GIT_PR && rootKind != event.KIND_GIT_PATCH {435 return mcpUnsigned{}, errors.New("file and line anchor a comment to a pull request or patch diff")436 }437 tags = append(tags, []string{"file", file}, []string{"line", strconv.Itoa(line), side})438 }439 return mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil440}441442func mcpBuildStatus(call mcp.Call) (mcpUnsigned, error) {443 owner, _, tags, err := mcpRepositoryTags(call)444 if err != nil {445 return mcpUnsigned{}, err446 }447 root, rootPubKey, status := call.String("root"), call.String("root_pubkey"), call.String("status")448 kind, known := mcpStatusKinds[status]449 if root == "" || rootPubKey == "" || !known {450 return mcpUnsigned{}, errors.New("root, root_pubkey and status are required")451 }452 tags = append(tags, []string{"e", root, "", "root"}, []string{"p", rootPubKey})453 if owner != rootPubKey {454 tags = append(tags, []string{"p", owner})455 }456 return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil457}458459func mcpCoordinateOwner(e event.Event) (string, error) {460 coordinate := event.Tag(e, "a")461 if !mcpCoordinate.MatchString(coordinate) {462 return "", errors.New(`the a tag must name the repository as 30617:<owner>:<repo>`)463 }464 return strings.Split(coordinate, ":")[1], nil465}466467func mcpCheckIssue(e event.Event) error {468 if e.Kind != event.KIND_GIT_ISSUE {469 return fmt.Errorf("kind %d is not an issue", e.Kind)470 }471 return mcpCheckRoot(e)472}473474func mcpCheckPull(e event.Event) error {475 if e.Kind != event.KIND_GIT_PR {476 return fmt.Errorf("kind %d is not a pull request", e.Kind)477 }478 if err := mcpCheckRoot(e); err != nil {479 return err480 }481 if !hex40(event.Tag(e, "c")) || (event.Tag(e, "merge-base") != "" && !hex40(event.Tag(e, "merge-base"))) {482 return errors.New("the c tag (and merge-base, when present) must be a full 40-hex commit ID")483 }484 return mcpCloneURL(event.Tag(e, "clone"))485}486487func mcpCheckRoot(e event.Event) error {488 owner, err := mcpCoordinateOwner(e)489 if err != nil {490 return err491 }492 if !contains(event.TagValues(e, "p"), owner) {493 return errors.New("a p tag must name the repository owner")494 }495 if strings.TrimSpace(event.Tag(e, "subject")) == "" {496 return errors.New("the subject tag carries the title and must not be empty")497 }498 if e.Kind == event.KIND_GIT_ISSUE && strings.TrimSpace(e.Content) == "" {499 return errors.New("content must not be empty")500 }501 return nil502}503504func mcpCheckComment(e event.Event) error {505 if e.Kind != 1111 {506 return fmt.Errorf("kind %d is not a comment", e.Kind)507 }508 if _, err := mcpCoordinateOwner(e); err != nil {509 return err510 }511 rootKind := event.Tag(e, "K")512 if !hex64(event.Tag(e, "E")) || !hex64(event.Tag(e, "P")) || !contains([]string{"1617", "1618", "1621"}, rootKind) {513 return errors.New("the E, K and P tags must name the root event, its kind (1617, 1618 or 1621) and its author")514 }515 if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) || !contains([]string{"1111", rootKind}, event.Tag(e, "k")) {516 return errors.New("the e, k and p tags must name the parent event, its kind and its author")517 }518 if strings.TrimSpace(e.Content) == "" {519 return errors.New("content must not be empty")520 }521 if err := validateReviewAnchor(e); err != nil {522 return errors.New(strings.TrimPrefix(err.Error(), "blocked: "))523 }524 return nil525}526527func mcpCheckStatus(e event.Event) error {528 if e.Kind < 1630 || e.Kind > 1633 {529 return fmt.Errorf("kind %d is not a status", e.Kind)530 }531 if _, err := mcpCoordinateOwner(e); err != nil {532 return err533 }534 if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) {535 return errors.New("the e and p tags must name the issue or pull request and its author")536 }537 return nil538}539540func mcpCloneURL(raw string) error {541 clone, err := url.Parse(raw)542 if err != nil || (clone.Scheme != "http" && clone.Scheme != "https") || clone.Host == "" || clone.User != nil || clone.Fragment != "" {543 return errors.New("clone must be an HTTP or HTTPS URL without credentials")544 }545 return nil546}547548func hex64(value string) bool { return len(value) == 64 && isHexLower(value) }549func hex40(value string) bool { return len(value) == 40 && isHexLower(value) }550func isHexLower(value string) bool {551 return value != "" && strings.Trim(value, "0123456789abcdef") == ""552}553554func merge(base, extra map[string]any) map[string]any {555 out := make(map[string]any, len(base)+len(extra))556 for key, value := range base {557 out[key] = value558 }559 for key, value := range extra {560 out[key] = value561 }562 return out563}564565// Room, wiki and decision builders. Each returns the unsigned event for the566// caller to sign; the matching check accepts the signed event back.567568func mcpRoomTag(call mcp.Call) ([][]string, error) {569 room := call.String("room")570 if !community.ValidRoomID(room) {571 return nil, errors.New("room is required and must be a room id")572 }573 return [][]string{{"h", room}}, nil574}575576func mcpPubKeys(call mcp.Call, key string, tags [][]string) [][]string {577 seen := map[string]bool{}578 if values, ok := call.Arguments[key].([]any); ok {579 for _, value := range values {580 text, _ := value.(string)581 if hex64(text) && !seen[text] {582 seen[text] = true583 tags = append(tags, []string{"p", text})584 }585 }586 }587 return tags588}589590func mcpBuildMessage(call mcp.Call) (mcpUnsigned, error) {591 tags, err := mcpRoomTag(call)592 if err != nil {593 return mcpUnsigned{}, err594 }595 content, attachmentTags, err := mcpRoomContent(call)596 if err != nil {597 return mcpUnsigned{}, err598 }599 if strings.TrimSpace(content) == "" {600 return mcpUnsigned{}, errors.New("content or attachments is required")601 }602 tags = append(tags, attachmentTags...)603 return mcpUnsigned{Kind: event.KIND_CHAT, CreatedAt: time.Now().Unix(), Tags: mcpPubKeys(call, "mentions", tags), Content: content}, nil604}605606func mcpBuildThread(call mcp.Call) (mcpUnsigned, error) {607 tags, err := mcpRoomTag(call)608 if err != nil {609 return mcpUnsigned{}, err610 }611 title, content := strings.TrimSpace(call.String("title")), call.String("content")612 content, attachmentTags, err := mcpRoomContent(call)613 if err != nil {614 return mcpUnsigned{}, err615 }616 if strings.TrimSpace(content) == "" {617 return mcpUnsigned{}, errors.New("content or attachments is required")618 }619 if title != "" {620 tags = append(tags, []string{"subject", title})621 }622 tags = append(tags, attachmentTags...)623 return mcpUnsigned{Kind: event.KIND_THREAD, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil624}625626func mcpBuildReply(call mcp.Call) (mcpUnsigned, error) {627 tags, err := mcpRoomTag(call)628 if err != nil {629 return mcpUnsigned{}, err630 }631 root, rootPubKey := call.String("root"), call.String("root_pubkey")632 content, attachmentTags, err := mcpRoomContent(call)633 if root == "" || strings.TrimSpace(content) == "" {634 return mcpUnsigned{}, errors.New("root and content or attachments are required")635 }636 tags = append(tags, []string{"e", root})637 if rootPubKey != "" {638 tags = append(tags, []string{"p", rootPubKey})639 }640 tags = append(tags, attachmentTags...)641 return mcpUnsigned{Kind: event.KIND_THREAD_REPLY, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil642}643644func mcpBuildReact(call mcp.Call) (mcpUnsigned, error) {645 target, targetPubKey, content, room := call.String("target"), call.String("target_pubkey"), call.String("content"), call.String("room")646 if target == "" || targetPubKey == "" || content == "" {647 return mcpUnsigned{}, errors.New("target, target_pubkey and content are required")648 }649 if !mcpReaction(content) {650 return mcpUnsigned{}, errors.New("content must be +, - or one emoji")651 }652 tags := [][]string{{"e", target}, {"p", targetPubKey}}653 if room != "" {654 tags = append(tags, []string{"h", room})655 }656 return mcpUnsigned{Kind: 7, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil657}658659func mcpBuildWikiPage(call mcp.Call) (mcpUnsigned, error) {660 d, title, content := wiki.Normalize(call.String("d")), strings.TrimSpace(call.String("title")), call.String("content")661 if d == "" || title == "" || strings.TrimSpace(content) == "" {662 return mcpUnsigned{}, errors.New("d, title and content are required")663 }664 tags := [][]string{{"d", d}, {"title", title}}665 if summary := strings.TrimSpace(call.String("summary")); summary != "" {666 tags = append(tags, []string{"summary", summary})667 }668 forkAuthor, forkEvent := call.String("fork_author"), call.String("fork_event")669 if (forkAuthor == "") != (forkEvent == "") {670 return mcpUnsigned{}, errors.New("a fork needs both fork_author and fork_event")671 }672 if forkAuthor != "" {673 tags = append(tags, []string{"a", "30818:" + forkAuthor + ":" + d, "", "fork"}, []string{"e", forkEvent, "", "fork"})674 }675 return mcpUnsigned{Kind: kindWikiArticle, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil676}677678func mcpBuildWikiMerge(call mcp.Call) (mcpUnsigned, error) {679 d, destination, source, base, content := wiki.Normalize(call.String("d")), call.String("destination"), call.String("source"), call.String("base"), call.String("content")680 if d == "" || destination == "" || source == "" || strings.TrimSpace(content) == "" {681 return mcpUnsigned{}, errors.New("d, destination, source and content are required")682 }683 tags := [][]string{{"a", "30818:" + destination + ":" + d}, {"p", destination}, {"e", source, "", "source"}}684 if base != "" {685 tags = append(tags, []string{"e", base})686 }687 return mcpUnsigned{Kind: kindWikiMerge, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil688}689690// mcpBuildSite builds a site manifest for the caller's own site or a named691// site under its key. The template passes the same validation as the signed692// event so a bad path is reported before anything is signed.693func mcpBuildSite(call mcp.Call) (mcpUnsigned, error) {694 kind, tags := sites.KindSite, [][]string{}695 if label := strings.TrimSpace(call.String("label")); label != "" {696 site, ok := sites.ParseSite(label)697 if !ok || site.Kind == sites.KindSiteSnapshot {698 return mcpUnsigned{}, errors.New("label must be your npub or a named site label under your key")699 }700 if site.PubKey != call.Actor {701 return mcpUnsigned{}, errors.New("label " + label + " is not a site under your key")702 }703 kind = site.Kind704 if site.Kind == sites.KindNamedSite {705 tags = append(tags, []string{"d", site.D})706 }707 }708 pairs, _ := call.Arguments["paths"].([]any)709 if len(pairs) == 0 {710 return mcpUnsigned{}, errors.New("paths is required: one [path, sha256] pair per file")711 }712 for _, pair := range pairs {713 values, _ := pair.([]any)714 if len(values) != 2 {715 return mcpUnsigned{}, errors.New("each entry of paths is a [path, sha256] pair")716 }717 path, _ := values[0].(string)718 hash, _ := values[1].(string)719 tags = append(tags, []string{"path", path, strings.ToLower(strings.TrimSpace(hash))})720 }721 if expiration := call.Int("expiration"); expiration > 0 {722 if int64(expiration) <= time.Now().Unix() {723 return mcpUnsigned{}, errors.New("expiration must be in the future")724 }725 tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})726 }727 if err := sites.ValidateManifest(event.Event{Kind: kind, PubKey: call.Actor, Tags: tags}); err != nil {728 return mcpUnsigned{}, errors.New(strings.TrimPrefix(err.Error(), "invalid: "))729 }730 return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil731}732733func mcpCheckSite(e event.Event) error {734 if e.Kind != sites.KindSite && e.Kind != sites.KindNamedSite {735 return fmt.Errorf("kind %d is not a site manifest", e.Kind)736 }737 if err := sites.ValidateManifest(e); err != nil {738 return errors.New(strings.TrimPrefix(err.Error(), "invalid: "))739 }740 return nil741}742743func mcpBuildRoom(call mcp.Call) (mcpUnsigned, error) {744 tags, err := mcpRoomTag(call)745 if err != nil {746 return mcpUnsigned{}, err747 }748 name := strings.TrimSpace(call.String("name"))749 if name == "" {750 return mcpUnsigned{}, errors.New("name is required")751 }752 tags = append(tags, []string{"name", name})753 if about := strings.TrimSpace(call.String("about")); about != "" {754 tags = append(tags, []string{"about", about})755 }756 if visibility := call.String("visibility"); visibility != "" {757 tags = append(tags, []string{"visibility", visibility})758 }759 return mcpUnsigned{Kind: event.KIND_CREATE_GROUP, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil760}761762func mcpBuildRequest(call mcp.Call) (mcpUnsigned, error) {763 pubkey, request, content := call.String("pubkey"), call.String("request"), call.String("content")764 if pubkey == "" || !contains(mcpRequestKinds, request) || strings.TrimSpace(content) == "" {765 return mcpUnsigned{}, errors.New("pubkey, request and content are required")766 }767 room, root, rootPubKey, rootKind := call.String("room"), call.String("root"), call.String("root_pubkey"), call.Int("root_kind")768 var kind int769 var tags [][]string770 switch {771 case room != "" && root != "":772 kind, tags = event.KIND_CHAT, [][]string{{"h", room}, {"e", root, "", "root"}}773 case room != "":774 kind, tags = event.KIND_CHAT, [][]string{{"h", room}}775 case root != "":776 if rootPubKey == "" || rootKind <= 0 {777 return mcpUnsigned{}, errors.New("root needs root_kind and root_pubkey")778 }779 k := strconv.Itoa(rootKind)780 kind, tags = 1111, [][]string{{"E", root, "", rootPubKey}, {"K", k}, {"P", rootPubKey}, {"e", root, "", rootPubKey}, {"k", k}}781 if rootPubKey != pubkey {782 tags = append(tags, []string{"p", rootPubKey})783 }784 default:785 return mcpUnsigned{}, errors.New("room or root is required")786 }787 tags = append(tags, []string{"request", request}, []string{"p", pubkey})788 if expiration := call.Int("expiration"); expiration > 0 {789 if int64(expiration) <= time.Now().Unix() {790 return mcpUnsigned{}, errors.New("expiration must be in the future")791 }792 tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})793 }794 if subject := strings.TrimSpace(call.String("subject")); subject != "" {795 tags = append(tags, []string{"subject", subject})796 }797 return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil798}799800func mcpCheckRoomEvent(e event.Event, kind int, what string) error {801 if e.Kind != kind {802 return fmt.Errorf("kind %d is not a %s", e.Kind, what)803 }804 if !community.ValidRoomID(event.Tag(e, "h")) {805 return errors.New("the h tag must name the room")806 }807 return nil808}809810func mcpCheckMessage(e event.Event) error {811 if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message"); err != nil {812 return err813 }814 if strings.TrimSpace(e.Content) == "" {815 return errors.New("content must not be empty")816 }817 return mcpCheckAttachments(e)818}819820func mcpCheckThread(e event.Event) error {821 if err := mcpCheckRoomEvent(e, event.KIND_THREAD, "thread"); err != nil {822 return err823 }824 if strings.TrimSpace(e.Content) == "" {825 return errors.New("content must not be empty")826 }827 return mcpCheckAttachments(e)828}829830func mcpCheckReply(e event.Event) error {831 if err := mcpCheckRoomEvent(e, event.KIND_THREAD_REPLY, "thread reply"); err != nil {832 return err833 }834 if !hex64(event.Tag(e, "e")) {835 return errors.New("the e tag must name the thread root")836 }837 if strings.TrimSpace(e.Content) == "" {838 return errors.New("content must not be empty")839 }840 return mcpCheckAttachments(e)841}842843func mcpCheckReact(e event.Event) error {844 if e.Kind != 7 {845 return fmt.Errorf("kind %d is not a reaction", e.Kind)846 }847 if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) {848 return errors.New("the e and p tags must name the event and its author")849 }850 if room := event.Tag(e, "h"); room != "" && !community.ValidRoomID(room) {851 return errors.New("the h tag must name a room")852 }853 if !mcpReaction(e.Content) {854 return errors.New("content must be +, - or one emoji")855 }856 return nil857}858859func mcpCheckWikiPage(e event.Event) error {860 if e.Kind != kindWikiArticle {861 return fmt.Errorf("kind %d is not a wiki page", e.Kind)862 }863 d := event.Tag(e, "d")864 if d == "" || d != wiki.Normalize(d) {865 return errors.New("the d tag must carry the normalized page name")866 }867 if strings.TrimSpace(event.Tag(e, "title")) == "" || strings.TrimSpace(e.Content) == "" {868 return errors.New("the title tag and content must not be empty")869 }870 for _, tag := range e.Tags {871 if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") {872 continue873 }874 if (tag[0] == "a" && !mcpWikiCoordinate.MatchString(tag[1])) || (tag[0] == "e" && !hex64(tag[1])) {875 return errors.New("fork and defer tags must name a 30818:<author>:<page> coordinate or a version id")876 }877 }878 return nil879}880881func mcpCheckWikiMerge(e event.Event) error {882 if e.Kind != kindWikiMerge {883 return fmt.Errorf("kind %d is not a merge request", e.Kind)884 }885 coordinate := event.Tag(e, "a")886 if !mcpWikiCoordinate.MatchString(coordinate) {887 return errors.New("the a tag must name the target as 30818:<destination>:<page>")888 }889 if destination := event.Tag(e, "p"); !hex64(destination) || destination != strings.Split(coordinate, ":")[1] {890 return errors.New("the p tag must name the destination author of the a tag")891 }892 source := ""893 for _, tag := range e.Tags {894 if len(tag) >= 4 && tag[0] == "e" && tag[3] == "source" {895 source = tag[1]896 }897 }898 if !hex64(source) {899 return errors.New("an e tag with the source marker must name the proposed version")900 }901 if strings.TrimSpace(e.Content) == "" {902 return errors.New("content must not be empty")903 }904 return nil905}906907func mcpCheckRoom(e event.Event) error {908 if err := mcpCheckRoomEvent(e, event.KIND_CREATE_GROUP, "room creation"); err != nil {909 return err910 }911 if strings.TrimSpace(event.Tag(e, "name")) == "" {912 return errors.New("the name tag must not be empty")913 }914 if visibility := event.Tag(e, "visibility"); visibility != "" && visibility != community.RoomOpen && visibility != community.RoomMembers {915 return errors.New("the visibility tag must be open or members")916 }917 return nil918}919920func mcpCheckRequest(e event.Event) error {921 switch e.Kind {922 case event.KIND_CHAT:923 if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message"); err != nil {924 return err925 }926 case 1111:927 if !hex64(event.Tag(e, "E")) || !hex64(event.Tag(e, "P")) || event.Tag(e, "K") == "" || !hex64(event.Tag(e, "e")) || event.Tag(e, "k") == "" {928 return errors.New("the E, K, P, e and k tags must name the root event, its kind and its author")929 }930 default:931 return fmt.Errorf("kind %d is not a chat message or comment", e.Kind)932 }933 if !contains(mcpRequestKinds, event.Tag(e, "request")) {934 return errors.New("the request tag must be approve, decide or question")935 }936 asked := event.TagValues(e, "p")937 if len(asked) == 0 || !hex64(asked[len(asked)-1]) {938 return errors.New("a p tag must name the person asked")939 }940 if expiration := event.Tag(e, "expiration"); expiration != "" {941 if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() {942 return errors.New("the expiration tag must be a Unix time in the future")943 }944 }945 if strings.TrimSpace(e.Content) == "" {946 return errors.New("content must not be empty")947 }948 return nil949}950951// Long task builders. A request names its room and the keys it asks;952// every answer and cancel names the request, the requester and the room.953954// mcpJobAnswerSchema is the plain-field schema shared by the answer tools.955// A result also takes artifacts.956func mcpJobAnswerSchema(artifacts bool) map[string]any {957 fields := map[string]any{"event": mcpEvent, "request": mcpJobEvent, "e": mcpHash, "p": mcpPubKey, "room": mcpRoomID, "content": mcpText}958 if artifacts {959 fields["artifacts"] = mcpArtifacts960 }961 return mcp.Object(fields)962}963964func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {965 room := strings.TrimSpace(call.String("room"))966 if !community.ValidRoomID(room) {967 return mcpUnsigned{}, errors.New("room is required and must be a room id")968 }969 tags := [][]string{{"h", room}}970 assignees, _ := call.Arguments["assignees"].([]any)971 for _, raw := range assignees {972 key, _ := raw.(string)973 if !hex64(key) {974 return mcpUnsigned{}, errors.New("assignees must be hex public keys")975 }976 tags = append(tags, []string{"p", key})977 }978 if len(assignees) == 0 {979 return mcpUnsigned{}, errors.New("assignees must name at least one key to ask")980 }981 subject, content := strings.TrimSpace(call.String("subject")), call.String("content")982 if subject == "" && strings.TrimSpace(content) == "" {983 return mcpUnsigned{}, errors.New("content or subject is required")984 }985 if subject != "" {986 tags = append(tags, []string{"subject", subject})987 }988 if root := call.String("root"); root != "" {989 if !hex64(root) {990 return mcpUnsigned{}, errors.New("root must be an event id")991 }992 tags = append(tags, []string{"e", root, "", "root"})993 }994 if expiration := call.Int("expiration"); expiration > 0 {995 if int64(expiration) <= time.Now().Unix() {996 return mcpUnsigned{}, errors.New("expiration must be in the future")997 }998 tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})999 }1000 return mcpUnsigned{Kind: event.KIND_JOB_REQUEST, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil1001}10021003// mcpJobRequestArgument reads the request event an answer tool was given.1004func mcpJobRequestArgument(call mcp.Call) (event.Event, bool, error) {1005 raw, ok := call.Arguments["request"].(map[string]any)1006 if !ok {1007 return event.Event{}, false, nil1008 }1009 var request event.Event1010 encoded, err := json.Marshal(raw)1011 if err == nil {1012 err = json.Unmarshal(encoded, &request)1013 }1014 if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) {1015 return event.Event{}, false, errors.New("request must be a kind 43001 job request event with id and pubkey")1016 }1017 return request, true, nil1018}10191020// mcpJobTarget resolves the request an answer names, from the request1021// event or from e, p and room, and checks that the two agree.1022func mcpJobTarget(call mcp.Call) (id, requester, room string, err error) {1023 id, requester, room = call.String("e"), call.String("p"), strings.TrimSpace(call.String("room"))1024 request, ok, err := mcpJobRequestArgument(call)1025 if err != nil {1026 return "", "", "", err1027 }1028 if ok {1029 if id == "" {1030 id = request.ID1031 }1032 if requester == "" {1033 requester = request.PubKey1034 }1035 if room == "" {1036 room = event.Tag(request, "h")1037 }1038 if id != request.ID || requester != request.PubKey || room != event.Tag(request, "h") {1039 return "", "", "", errors.New("e, p and room must match the request: its id, its author and its h tag")1040 }1041 }1042 if !hex64(id) || !hex64(requester) || !community.ValidRoomID(room) {1043 return "", "", "", errors.New("request, or e (the request id), p (the requester) and room, are required")1044 }1045 return id, requester, room, nil1046}10471048// mcpBuildJobAnswer builds an accepted, progress, result or error event.1049// Progress, results and errors need content; a result may add artifacts.1050func mcpBuildJobAnswer(kind int) func(mcp.Call) (mcpUnsigned, error) {1051 return func(call mcp.Call) (mcpUnsigned, error) {1052 id, requester, room, err := mcpJobTarget(call)1053 if err != nil {1054 return mcpUnsigned{}, err1055 }1056 content := call.String("content")1057 if kind != event.KIND_JOB_ACCEPTED && strings.TrimSpace(content) == "" {1058 return mcpUnsigned{}, errors.New("content is required")1059 }1060 tags := [][]string{{"h", room}, {"e", id}, {"p", requester}}1061 if artifacts, ok := call.Arguments["artifacts"].([]any); ok && kind == event.KIND_JOB_RESULT {1062 for _, raw := range artifacts {1063 artifact, _ := raw.(map[string]any)1064 name, _ := artifact["type"].(string)1065 value, _ := artifact["value"].(string)1066 if !mcpJobArtifactOK(name, strings.TrimSpace(value)) {1067 return mcpUnsigned{}, errors.New("each artifact needs a type of e (an event id), a (a kind:pubkey:identifier coordinate) or r (an http or https URL) and a matching value")1068 }1069 tags = append(tags, []string{name, strings.TrimSpace(value)})1070 }1071 }1072 return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil1073 }1074}10751076// mcpJobArtifactOK reports whether one artifact reference is well formed.1077func mcpJobArtifactOK(name, value string) bool {1078 switch name {1079 case "e":1080 return hex64(value)1081 case "a":1082 parts := strings.SplitN(value, ":", 3)1083 if len(parts) != 3 || !hex64(parts[1]) {1084 return false1085 }1086 _, err := strconv.Atoi(parts[0])1087 return err == nil1088 case "r":1089 return strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://")1090 }1091 return false1092}10931094func mcpBuildJobCancel(call mcp.Call) (mcpUnsigned, error) {1095 id, room := call.String("e"), strings.TrimSpace(call.String("room"))1096 request, ok, err := mcpJobRequestArgument(call)1097 if err != nil {1098 return mcpUnsigned{}, err1099 }1100 if ok {1101 if id == "" {1102 id = request.ID1103 }1104 if room == "" {1105 room = event.Tag(request, "h")1106 }1107 if id != request.ID || room != event.Tag(request, "h") {1108 return mcpUnsigned{}, errors.New("e and room must match the request: its id and its h tag")1109 }1110 }1111 if !hex64(id) || !community.ValidRoomID(room) {1112 return mcpUnsigned{}, errors.New("request, or e (the request id) and room, are required")1113 }1114 tags := [][]string{{"h", room}, {"e", id}}1115 if assignee := call.String("p"); assignee != "" {1116 if !hex64(assignee) {1117 return mcpUnsigned{}, errors.New("p must be a hex public key")1118 }1119 tags = append(tags, []string{"p", assignee})1120 }1121 return mcpUnsigned{Kind: event.KIND_JOB_CANCEL, CreatedAt: time.Now().Unix(), Tags: tags, Content: call.String("content")}, nil1122}11231124// mcpJobShape reports the gate's shape check without its NIP-01 prefix, so1125// the tool's message reads as one sentence.1126func mcpJobShape(e event.Event) error {1127 if err := gates.JobShape(e); err != nil {1128 return errors.New(strings.TrimPrefix(err.Error(), "invalid: "))1129 }1130 return nil1131}11321133func mcpCheckJobRequest(e event.Event) error {1134 if !event.IsJobRequest(e.Kind) {1135 return fmt.Errorf("kind %d is not a job request", e.Kind)1136 }1137 if expiration := event.Tag(e, "expiration"); expiration != "" {1138 if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() {1139 return errors.New("the expiration tag must be a Unix time in the future")1140 }1141 }1142 if strings.TrimSpace(e.Content) == "" && strings.TrimSpace(event.Tag(e, "subject")) == "" {1143 return errors.New("content or a subject tag is required")1144 }1145 return mcpJobShape(e)1146}11471148func mcpCheckJobAnswer(kind int) func(event.Event) error {1149 return func(e event.Event) error {1150 if e.Kind != kind {1151 return fmt.Errorf("kind %d is not a %s", e.Kind, gates.JobKindName(kind))1152 }1153 if kind != event.KIND_JOB_ACCEPTED && strings.TrimSpace(e.Content) == "" {1154 return errors.New("content must not be empty")1155 }1156 if kind == event.KIND_JOB_RESULT {1157 first := true1158 for _, tag := range e.Tags {1159 if len(tag) < 2 {1160 continue1161 }1162 if tag[0] == "e" && first {1163 first = false1164 continue1165 }1166 if (tag[0] == "e" || tag[0] == "a" || tag[0] == "r") && !mcpJobArtifactOK(tag[0], tag[1]) {1167 return fmt.Errorf("the %s artifact tag %q is not an event id, a coordinate or an http or https URL", tag[0], tag[1])1168 }1169 }1170 }1171 return mcpJobShape(e)1172 }1173}11741175func mcpCheckJobCancel(e event.Event) error {1176 if !event.IsJobCancel(e.Kind) {1177 return fmt.Errorf("kind %d is not a job cancel", e.Kind)1178 }1179 return mcpJobShape(e)1180}11811182// mcpReaction accepts NIP-25 content: +, - or one emoji, which may be a1183// sequence of a few non-ASCII code points such as a flag or a skin tone.1184func mcpReaction(content string) bool {1185 if content == "+" || content == "-" {1186 return true1187 }1188 runes := []rune(content)1189 if len(runes) == 0 || len(runes) > 10 {1190 return false1191 }1192 for _, r := range runes {1193 if r < 0x80 || unicode.IsSpace(r) || unicode.IsLetter(r) || unicode.IsDigit(r) {1194 return false1195 }1196 }1197 return true1198}