|
| 1 | diff --git a/docs/mcp.md b/docs/mcp.md | | 2 | index 5f99064..57a3da4 100644 | | 3 | --- a/docs/mcp.md | | 4 | +++ b/docs/mcp.md | | 5 | @@ -67,6 +67,8 @@ Read tools, which need a key that may read the relay: | | 6 | | `read_wiki_page` | Read a page by `d` with its versions, merge requests and redirects. `author` or `version` selects the version shown. | | | 7 | | `read_merge_request` | Read a wiki merge request by `id` with its status, proposed version and target version. | | | 8 | | `list_agents` | List granted agents with their state, scope and last event, as an owner or moderator. | | | 9 | +| `list_jobs` | List long task requests visible to the key with each one's newest feedback status and result. `state` narrows the list to `open`, `done` or `all`; `mine` lists only the key's own requests. | | | 10 | +| `read_job` | Read one long task request by `id` with its feedback timeline and results. | | | 11 | | | 12 | Management tools, which follow the relay's roles: | | 13 | | | 14 | @@ -103,6 +105,9 @@ Write tools, which publish through the same path as `POST /events`: | | 15 | | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. | | | 16 | | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. | | | 17 | | `request_decision` | Ask a person to approve, decide or answer with a kind 9 room message or a kind 1111 comment carrying a `request` tag. | | | 18 | +| `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5999 carrying its inputs, output type, parameters, bid and relays. | | | 19 | +| `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. | | | 20 | +| `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. | | | 21 | | | 22 | The relay never signs on a caller's behalf. Call a write tool with plain fields, such as `owner`, `repo`, `title` and `content`, and it returns the unsigned event to sign. Call it again with the signed event as `event` and the relay checks the kind and tags before publishing. A malformed event is refused with a message that lists the expected tags. | | 23 | | | 24 | @@ -112,6 +117,12 @@ The relay never signs on a caller's behalf. Call a write tool with plain fields, | | 25 | | | 26 | The person answers with a kind 7 reaction to the published event from the asked key: `+` approves, `-` declines and any other content is their reply. Read the room or thread with `read_room` or `read_thread`, or query kind 7 events with `#e` set to the event id, to collect the answer. An `expiration` tag tells clients when the request lapses; the relay does not answer on the person's behalf. | | 27 | | | 28 | +### Long tasks | | 29 | + | | 30 | +`request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5999) and `inputs`, each with `data` and a `type` of `url`, `event`, `job` or `text` plus an optional `relay` and `marker`, and any of `output`, `params`, `bid` in millisats, `relays` and `expiration`. Each input becomes an `i` tag and each parameter a `param` tag. | | 31 | + | | 32 | +A serving agent answers with `job_feedback`, which takes `e` (the request id), `p` (the requester) and `status` (`payment-required`, `processing`, `error`, `success` or `partial`) plus optional `info`, `amount`, `invoice` and `content`, and then with `job_result`, which takes the `request` event and `content` plus optional `amount` and `invoice`. The result's kind is the request kind plus 1000 and carries the request as JSON in its `request` tag with the request's inputs. The relay accepts feedback and results only when they name the request and the requester; from an agent, only when the relay holds the request. `list_jobs` and `read_job` follow the work. See [Long tasks](agents.md#long-tasks). | | 33 | + | | 34 | ## Example | | 35 | | | 36 | A `tools/call` request for `read_management`, with the NIP-98 token abbreviated: | | 37 | diff --git a/internal/daemon/jobs_test.go b/internal/daemon/jobs_test.go | | 38 | index ec21113..b5b51b4 100644 | | 39 | --- a/internal/daemon/jobs_test.go | | 40 | +++ b/internal/daemon/jobs_test.go | | 41 | @@ -3,6 +3,8 @@ package daemon | | 42 | import ( | | 43 | "context" | | 44 | "encoding/json" | | 45 | + "net/http" | | 46 | + "net/http/httptest" | | 47 | "strconv" | | 48 | "strings" | | 49 | "testing" | | 50 | @@ -30,6 +32,143 @@ func jobTenant(t *testing.T) (*App, *Tenant, string, string) { | | 51 | return app, tenant, member, agent | | 52 | } | | 53 | | | 54 | +func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) { | | 55 | + app, _, member, _ := jobTenant(t) | | 56 | + now := time.Now().Unix() | | 57 | + call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) { | | 58 | + t.Helper() | | 59 | + w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp") | | 60 | + if w.Code != http.StatusOK { | | 61 | + t.Fatalf("%s: %d %s", name, w.Code, w.Body.String()) | | 62 | + } | | 63 | + return mcpToolResult(t, response) | | 64 | + } | | 65 | + structured := func(result map[string]any) map[string]any { | | 66 | + value, _ := result["structuredContent"].(map[string]any) | | 67 | + return value | | 68 | + } | | 69 | + text := func(result map[string]any) string { | | 70 | + content, _ := result["content"].([]any) | | 71 | + if len(content) == 0 { | | 72 | + return "" | | 73 | + } | | 74 | + return content[0].(map[string]any)["text"].(string) | | 75 | + } | | 76 | + _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp") | | 77 | + names := map[string]bool{} | | 78 | + for _, tool := range response.Result.(map[string]any)["tools"].([]any) { | | 79 | + names[tool.(map[string]any)["name"].(string)] = true | | 80 | + } | | 81 | + for _, want := range []string{"list_jobs", "read_job", "request_job", "job_feedback", "job_result"} { | | 82 | + if !names[want] { | | 83 | + t.Fatalf("missing tool %s", want) | | 84 | + } | | 85 | + } | | 86 | + | | 87 | + // request_job: the template carries the NIP-90 tags in order, refuses | | 88 | + // bad inputs and kinds, and the signed request publishes. | | 89 | + source := strings.Repeat("d", 64) | | 90 | + expires := strconv.FormatInt(now+3600, 10) | | 91 | + result, isError := call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "text"}, map[string]any{"data": source, "type": "event", "relay": "wss://relay.example", "marker": "context"}}, "output": "text/plain", "params": map[string]any{"model": "small", "lang": "es"}, "bid": 1000, "relays": []any{"wss://a.example", " "}, "expiration": now + 3600}, testMemberSecret) | | 92 | + unsigned, _ := structured(result)["unsigned"].(map[string]any) | | 93 | + tags, _ := json.Marshal(unsigned["tags"]) | | 94 | + if isError || unsigned["kind"] != float64(5001) || unsigned["content"] != "" || string(tags) != `[["i","hello","text"],["i","`+source+`","event","wss://relay.example","context"],["output","text/plain"],["param","lang","es"],["param","model","small"],["bid","1000"],["relays","wss://a.example"],["expiration","`+expires+`"]]` { | | 95 | + t.Fatalf("request_job template: %v %s", isError, text(result)) | | 96 | + } | | 97 | + if result, isError = call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "voice"}}}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job") { | | 98 | + t.Fatalf("request_job bad input: %s", text(result)) | | 99 | + } | | 100 | + if result, isError = call("request_job", map[string]any{"kind": 1}, testMemberSecret); !isError { | | 101 | + t.Fatalf("request_job bad kind: %s", text(result)) | | 102 | + } | | 103 | + if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello"}}, "")}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job or text") || !strings.Contains(text(result), "Expected a signed event of kind 5000 to 5999") { | | 104 | + t.Fatalf("request_job malformed signed: %s", text(result)) | | 105 | + } | | 106 | + request := mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "") | | 107 | + result, isError = call("request_job", map[string]any{"event": request}, testMemberSecret) | | 108 | + requestID, _ := structured(result)["event_id"].(string) | | 109 | + if isError || structured(result)["accepted"] != true || requestID == "" { | | 110 | + t.Fatalf("request_job publish: %s", text(result)) | | 111 | + } | | 112 | + | | 113 | + // job_feedback: the serving agent's template and signed publish, and | | 114 | + // the status vocabulary. | | 115 | + result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "processing", "info": "halfway", "amount": 500, "invoice": "lnbc1"}, testAgentSecret) | | 116 | + unsigned, _ = structured(result)["unsigned"].(map[string]any) | | 117 | + tags, _ = json.Marshal(unsigned["tags"]) | | 118 | + if isError || unsigned["kind"] != float64(7000) || string(tags) != `[["status","processing","halfway"],["e","`+requestID+`"],["p","`+member+`"],["amount","500","lnbc1"]]` { | | 119 | + t.Fatalf("job_feedback template: %v %s", isError, text(result)) | | 120 | + } | | 121 | + if result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "done"}, testAgentSecret); !isError || !strings.Contains(text(result), "status") { | | 122 | + t.Fatalf("job_feedback bad status: %s", text(result)) | | 123 | + } | | 124 | + if result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"e", requestID}, {"p", member}}, "")}, testAgentSecret); !isError || !strings.Contains(text(result), "payment-required, processing, error, success or partial") { | | 125 | + t.Fatalf("job_feedback malformed signed: %s", text(result)) | | 126 | + } | | 127 | + result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"status", "processing", "halfway"}, {"e", requestID}, {"p", member}}, "")}, testAgentSecret) | | 128 | + if isError || structured(result)["accepted"] != true { | | 129 | + t.Fatalf("job_feedback publish: %s", text(result)) | | 130 | + } | | 131 | + | | 132 | + // job_result: the template derives the kind, the request tag and the | | 133 | + // inputs from the request; the relay checks the answer against the | | 134 | + // request it holds. | | 135 | + result, isError = call("job_result", map[string]any{"request": request, "content": "hola", "amount": 700}, testAgentSecret) | | 136 | + unsigned, _ = structured(result)["unsigned"].(map[string]any) | | 137 | + resultTags, _ := unsigned["tags"].([]any) | | 138 | + if isError || unsigned["kind"] != float64(6001) || unsigned["content"] != "hola" || len(resultTags) != 5 { | | 139 | + t.Fatalf("job_result template: %v %s", isError, text(result)) | | 140 | + } | | 141 | + requestTag := resultTags[0].([]any) | | 142 | + var embedded event.Event | | 143 | + if requestTag[0] != "request" || json.Unmarshal([]byte(requestTag[1].(string)), &embedded) != nil || embedded.ID != requestID { | | 144 | + t.Fatalf("job_result request tag %v", requestTag) | | 145 | + } | | 146 | + tags, _ = json.Marshal(resultTags[1:]) | | 147 | + if string(tags) != `[["e","`+requestID+`"],["i","hello","text"],["p","`+member+`"],["amount","700"]]` { | | 148 | + t.Fatalf("job_result tags %s", tags) | | 149 | + } | | 150 | + if result, isError = call("job_result", map[string]any{"request": request, "kind": 6002, "content": "hola"}, testAgentSecret); !isError || !strings.Contains(text(result), "must match the request") { | | 151 | + t.Fatalf("job_result mismatched kind: %s", text(result)) | | 152 | + } | | 153 | + if result, isError = call("job_result", map[string]any{"kind": 6001, "e": requestID, "p": member}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") { | | 154 | + t.Fatalf("job_result without content: %s", text(result)) | | 155 | + } | | 156 | + if result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6002, [][]string{{"e", requestID}, {"p", member}}, "hola")}, testAgentSecret); !isError || !strings.Contains(text(result), "does not answer a kind 5001 request") { | | 157 | + t.Fatalf("job_result wrong kind rejected by relay: %s", text(result)) | | 158 | + } | | 159 | + signedTags := [][]string{} | | 160 | + for _, tag := range resultTags { | | 161 | + values := []string{} | | 162 | + for _, value := range tag.([]any) { | | 163 | + values = append(values, value.(string)) | | 164 | + } | | 165 | + signedTags = append(signedTags, values) | | 166 | + } | | 167 | + result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6001, signedTags, "hola")}, testAgentSecret) | | 168 | + resultID, _ := structured(result)["event_id"].(string) | | 169 | + if isError || structured(result)["accepted"] != true || resultID == "" { | | 170 | + t.Fatalf("job_result publish: %s", text(result)) | | 171 | + } | | 172 | + | | 173 | + // The reads show the settled request. | | 174 | + result, isError = call("list_jobs", map[string]any{"state": "done", "mine": true}, testMemberSecret) | | 175 | + items, _ := structured(result)["items"].([]any) | | 176 | + if isError || len(items) != 1 || items[0].(map[string]any)["id"] != requestID || items[0].(map[string]any)["status"] != "processing" || items[0].(map[string]any)["result"].(map[string]any)["id"] != resultID { | | 177 | + t.Fatalf("list_jobs: %s", text(result)) | | 178 | + } | | 179 | + result, isError = call("read_job", map[string]any{"id": requestID}, testMemberSecret) | | 180 | + if isError || len(structured(result)["feedback"].([]any)) != 1 || len(structured(result)["results"].([]any)) != 1 { | | 181 | + t.Fatalf("read_job: %s", text(result)) | | 182 | + } | | 183 | + r := httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil) | | 184 | + w := httptest.NewRecorder() | | 185 | + app.ServeHTTP(w, r) | | 186 | + if !strings.Contains(w.Body.String(), "request_job, job_feedback and job_result") || !strings.Contains(w.Body.String(), "list_jobs and read_job") { | | 187 | + t.Fatalf("llms.txt: %s", w.Body.String()) | | 188 | + } | | 189 | +} | | 190 | + | | 191 | func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) { | | 192 | _, tenant, member, agent := jobTenant(t) | | 193 | ctx := context.Background() | | 194 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go | | 195 | index 4c73a97..bf07727 100644 | | 196 | --- a/internal/daemon/mcp_http.go | | 197 | +++ b/internal/daemon/mcp_http.go | | 198 | @@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error { | | 199 | t.mcp = &mcp.Server{ | | 200 | Tools: tools, | | 201 | Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version}, | | 202 | - Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. request_decision asks a person for an answer that arrives as a kind 7 reaction.", | | 203 | + Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work.", | | 204 | Authenticate: t.mcpAuthenticate, | | 205 | Challenge: mcpChallenge, | | 206 | Origin: t.mcpOrigin, | | 207 | @@ -91,9 +91,9 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) { | | 208 | "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.", | | 209 | "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.", | | 210 | "- Methods: server/discover, tools/list and tools/call.", | | 211 | - "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request and list_agents.", | | 212 | + "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_jobs and read_job.", | | 213 | "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents and resume_all_agents.", | | 214 | - "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, create_room and request_decision. A decision request is answered by a kind 7 reaction from the person asked.", | | 215 | + "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000.", | | 216 | "", | | 217 | "## HTTP bridge", | | 218 | "", | | 219 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go | | 220 | index 884f469..ad18c1d 100644 | | 221 | --- a/internal/daemon/mcp_tools.go | | 222 | +++ b/internal/daemon/mcp_tools.go | | 223 | @@ -8,6 +8,7 @@ import ( | | 224 | "net" | | 225 | "net/url" | | 226 | "regexp" | | 227 | + "sort" | | 228 | "strconv" | | 229 | "strings" | | 230 | "time" | | 231 | @@ -15,6 +16,7 @@ import ( | | 232 | | | 233 | "github.com/FelineStateMachine/tinyrelay/internal/community" | | 234 | "github.com/FelineStateMachine/tinyrelay/internal/event" | | 235 | + "github.com/FelineStateMachine/tinyrelay/internal/gates" | | 236 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" | | 237 | "github.com/FelineStateMachine/tinyrelay/internal/relay" | | 238 | "github.com/FelineStateMachine/tinyrelay/internal/wiki" | | 239 | @@ -38,6 +40,10 @@ var ( | | 240 | mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."} | | 241 | mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."} | | 242 | mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."} | | 243 | + mcpJobKind = map[string]any{"type": "integer", "minimum": event.KIND_JOB_REQUEST_MIN, "maximum": event.KIND_JOB_REQUEST_MAX, "description": "NIP-90 job request kind, 5000 to 5999."} | | 244 | + mcpMsats = map[string]any{"type": "integer", "minimum": 0, "description": "Amount in millisats."} | | 245 | + mcpJobStatus = map[string]any{"type": "string", "enum": event.JobFeedbackStatuses} | | 246 | + 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."} | | 247 | mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false} | | 248 | mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false} | | 249 | mcpControls = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false} | | 250 | @@ -52,20 +58,23 @@ var ( | | 251 | ) | | 252 | | | 253 | const ( | | 254 | - 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.` | | 255 | - 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.` | | 256 | - 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.` | | 257 | - 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>"].` | | 258 | - mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.` | | 259 | - mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.` | | 260 | - 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.` | | 261 | - 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.` | | 262 | - 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.` | | 263 | - 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.` | | 264 | - mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].` | | 265 | - mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"], 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.` | | 266 | - mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument." | | 267 | - mcpAnswerNote = " The answer arrives as a kind 7 reaction from the asked key on the published event: + approves, - declines, and any other content is the person's reply. Read the room or thread, or query kind 7 events with #e set to the event id, to collect it." | | 268 | + 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.` | | 269 | + 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.` | | 270 | + 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.` | | 271 | + 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>"].` | | 272 | + mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.` | | 273 | + mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.` | | 274 | + 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.` | | 275 | + 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.` | | 276 | + 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.` | | 277 | + 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.` | | 278 | + mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].` | | 279 | + mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"], 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.` | | 280 | + mcpJobRequestShape = `Expected a signed event of kind 5000 to 5999 with optional tags ["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.` | | 281 | + 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>"] and optional ["amount","<millisats>","<bolt11>"], with content empty or a partial result.` | | 282 | + mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.` | | 283 | + mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument." | | 284 | + mcpAnswerNote = " The answer arrives as a kind 7 reaction from the asked key on the published event: + approves, - declines, and any other content is the person's reply. Read the room or thread, or query kind 7 events with #e set to the event id, to collect it." | | 285 | ) | | 286 | | | 287 | // mcpUnsigned is the event body a client signs before calling a write tool | | 288 | @@ -117,6 +126,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { | | 289 | 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) { | | 290 | return t.mcpManage(ctx, call, "listagents") | | 291 | }) | | 292 | + 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")) | | 293 | + 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")) | | 294 | | | 295 | 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) { | | 296 | return t.mcpManage(ctx, call, "runjob", call.String("id")) | | 297 | @@ -171,6 +182,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { | | 298 | 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)) | | 299 | 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)) | | 300 | add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey (the person asked), request (approve, decide or question) and content, plus room for a kind 9 room message or root, root_kind and root_pubkey for a kind 1111 comment under an issue, pull request or other event, and optional expiration and subject, to receive the unsigned event carrying a request tag, sign it, then call again with the signed event."+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)) | | 301 | + add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind (5000 to 5999) and inputs, plus optional output, params, bid in millisats, relays and expiration, to receive the unsigned event, sign it, then call again with the signed event. A serving agent answers with job feedback and a result naming the request; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "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)) | | 302 | + add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e (the request id), p (the requester) and status (payment-required, processing, error, success or partial), plus optional info, amount in millisats, invoice and content, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape)) | | 303 | + 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 amount and invoice, to receive the unsigned event of the request kind plus 1000, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "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)) | | 304 | return registry, err | | 305 | } | | 306 | | | 307 | @@ -769,6 +783,213 @@ func mcpCheckRequest(e event.Event) error { | | 308 | return nil | | 309 | } | | 310 | | | 311 | +// Long task builders. A request carries its inputs and terms as NIP-90 | | 312 | +// tags; feedback and results name the request and the requester. | | 313 | + | | 314 | +func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) { | | 315 | + kind := call.Int("kind") | | 316 | + if !event.IsJobRequest(kind) { | | 317 | + return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5999") | | 318 | + } | | 319 | + tags := [][]string{} | | 320 | + if inputs, ok := call.Arguments["inputs"].([]any); ok { | | 321 | + for _, raw := range inputs { | | 322 | + input, _ := raw.(map[string]any) | | 323 | + data, _ := input["data"].(string) | | 324 | + kind, _ := input["type"].(string) | | 325 | + relayHint, _ := input["relay"].(string) | | 326 | + marker, _ := input["marker"].(string) | | 327 | + if data == "" || !event.IsJobInputType(kind) { | | 328 | + return mcpUnsigned{}, errors.New("each input needs data and a type of url, event, job or text") | | 329 | + } | | 330 | + if (kind == "event" || kind == "job") && !hex64(data) { | | 331 | + return mcpUnsigned{}, errors.New("an input of type event or job must name an event id") | | 332 | + } | | 333 | + tag := []string{"i", data, kind} | | 334 | + if relayHint != "" || marker != "" { | | 335 | + tag = append(tag, relayHint) | | 336 | + } | | 337 | + if marker != "" { | | 338 | + tag = append(tag, marker) | | 339 | + } | | 340 | + tags = append(tags, tag) | | 341 | + } | | 342 | + } | | 343 | + if output := strings.TrimSpace(call.String("output")); output != "" { | | 344 | + tags = append(tags, []string{"output", output}) | | 345 | + } | | 346 | + if params, ok := call.Arguments["params"].(map[string]any); ok { | | 347 | + keys := make([]string, 0, len(params)) | | 348 | + for key := range params { | | 349 | + keys = append(keys, key) | | 350 | + } | | 351 | + sort.Strings(keys) | | 352 | + for _, key := range keys { | | 353 | + value, ok := params[key].(string) | | 354 | + if key == "" || !ok { | | 355 | + return mcpUnsigned{}, errors.New("params must map names to string values") | | 356 | + } | | 357 | + tags = append(tags, []string{"param", key, value}) | | 358 | + } | | 359 | + } | | 360 | + if _, present := call.Arguments["bid"]; present { | | 361 | + bid := call.Int("bid") | | 362 | + if bid < 0 { | | 363 | + return mcpUnsigned{}, errors.New("bid must be an amount in millisats") | | 364 | + } | | 365 | + tags = append(tags, []string{"bid", strconv.Itoa(bid)}) | | 366 | + } | | 367 | + if relays, ok := call.Arguments["relays"].([]any); ok { | | 368 | + tag := []string{"relays"} | | 369 | + for _, value := range relays { | | 370 | + if text, _ := value.(string); strings.TrimSpace(text) != "" { | | 371 | + tag = append(tag, strings.TrimSpace(text)) | | 372 | + } | | 373 | + } | | 374 | + if len(tag) > 1 { | | 375 | + tags = append(tags, tag) | | 376 | + } | | 377 | + } | | 378 | + if expiration := call.Int("expiration"); expiration > 0 { | | 379 | + if int64(expiration) <= time.Now().Unix() { | | 380 | + return mcpUnsigned{}, errors.New("expiration must be in the future") | | 381 | + } | | 382 | + tags = append(tags, []string{"expiration", strconv.Itoa(expiration)}) | | 383 | + } | | 384 | + return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil | | 385 | +} | | 386 | + | | 387 | +// mcpJobAmount appends the amount tag a provider asks to be paid, with its | | 388 | +// invoice when one is given. | | 389 | +func mcpJobAmount(call mcp.Call, tags [][]string) ([][]string, error) { | | 390 | + if _, present := call.Arguments["amount"]; !present { | | 391 | + return tags, nil | | 392 | + } | | 393 | + amount := call.Int("amount") | | 394 | + if amount < 0 { | | 395 | + return nil, errors.New("amount must be in millisats") | | 396 | + } | | 397 | + tag := []string{"amount", strconv.Itoa(amount)} | | 398 | + if invoice := strings.TrimSpace(call.String("invoice")); invoice != "" { | | 399 | + tag = append(tag, invoice) | | 400 | + } | | 401 | + return append(tags, tag), nil | | 402 | +} | | 403 | + | | 404 | +func mcpBuildJobFeedback(call mcp.Call) (mcpUnsigned, error) { | | 405 | + request, requester, status := call.String("e"), call.String("p"), call.String("status") | | 406 | + if !hex64(request) || !hex64(requester) || !event.IsJobFeedbackStatus(status) { | | 407 | + return mcpUnsigned{}, errors.New("e, p and status are required; status must be payment-required, processing, error, success or partial") | | 408 | + } | | 409 | + statusTag := []string{"status", status} | | 410 | + if info := strings.TrimSpace(call.String("info")); info != "" { | | 411 | + statusTag = append(statusTag, info) | | 412 | + } | | 413 | + tags, err := mcpJobAmount(call, [][]string{statusTag, {"e", request}, {"p", requester}}) | | 414 | + if err != nil { | | 415 | + return mcpUnsigned{}, err | | 416 | + } | | 417 | + return mcpUnsigned{Kind: event.KIND_JOB_FEEDBACK, CreatedAt: time.Now().Unix(), Tags: tags, Content: call.String("content")}, nil | | 418 | +} | | 419 | + | | 420 | +func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) { | | 421 | + var request event.Event | | 422 | + hasRequest := false | | 423 | + if raw, ok := call.Arguments["request"].(map[string]any); ok { | | 424 | + encoded, err := json.Marshal(raw) | | 425 | + if err == nil { | | 426 | + err = json.Unmarshal(encoded, &request) | | 427 | + } | | 428 | + if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) { | | 429 | + return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5999") | | 430 | + } | | 431 | + hasRequest = true | | 432 | + } | | 433 | + kind, id, requester, content := call.Int("kind"), call.String("e"), call.String("p"), call.String("content") | | 434 | + if hasRequest { | | 435 | + if kind == 0 { | | 436 | + kind = event.JobResultKind(request.Kind) | | 437 | + } | | 438 | + if id == "" { | | 439 | + id = request.ID | | 440 | + } | | 441 | + if requester == "" { | | 442 | + requester = request.PubKey | | 443 | + } | | 444 | + if kind != event.JobResultKind(request.Kind) || id != request.ID || requester != request.PubKey { | | 445 | + return mcpUnsigned{}, errors.New("kind, e and p must match the request: its kind plus 1000, its id and its author") | | 446 | + } | | 447 | + } | | 448 | + if !event.IsJobResult(kind) || !hex64(id) || !hex64(requester) { | | 449 | + return mcpUnsigned{}, errors.New("request, or kind (the request kind plus 1000), e and p, are required") | | 450 | + } | | 451 | + if strings.TrimSpace(content) == "" { | | 452 | + return mcpUnsigned{}, errors.New("content is required") | | 453 | + } | | 454 | + tags := [][]string{} | | 455 | + if hasRequest { | | 456 | + canonical, err := event.Canonical(request) | | 457 | + if err != nil { | | 458 | + return mcpUnsigned{}, err | | 459 | + } | | 460 | + tags = append(tags, []string{"request", string(canonical)}) | | 461 | + } | | 462 | + tags = append(tags, []string{"e", id}) | | 463 | + if hasRequest { | | 464 | + for _, tag := range request.Tags { | | 465 | + if len(tag) > 1 && tag[0] == "i" { | | 466 | + tags = append(tags, append([]string(nil), tag...)) | | 467 | + } | | 468 | + } | | 469 | + } | | 470 | + tags, err := mcpJobAmount(call, append(tags, []string{"p", requester})) | | 471 | + if err != nil { | | 472 | + return mcpUnsigned{}, err | | 473 | + } | | 474 | + return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil | | 475 | +} | | 476 | + | | 477 | +// mcpJobShape reports the gate's shape check without its NIP-01 prefix, so | | 478 | +// the tool's message reads as one sentence. | | 479 | +func mcpJobShape(e event.Event) error { | | 480 | + if err := gates.JobShape(e); err != nil { | | 481 | + return errors.New(strings.TrimPrefix(err.Error(), "invalid: ")) | | 482 | + } | | 483 | + return nil | | 484 | +} | | 485 | + | | 486 | +func mcpCheckJobRequest(e event.Event) error { | | 487 | + if !event.IsJobRequest(e.Kind) { | | 488 | + return fmt.Errorf("kind %d is not a job request", e.Kind) | | 489 | + } | | 490 | + if expiration := event.Tag(e, "expiration"); expiration != "" { | | 491 | + if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() { | | 492 | + return errors.New("the expiration tag must be a Unix time in the future") | | 493 | + } | | 494 | + } | | 495 | + return mcpJobShape(e) | | 496 | +} | | 497 | + | | 498 | +func mcpCheckJobFeedback(e event.Event) error { | | 499 | + if e.Kind != event.KIND_JOB_FEEDBACK { | | 500 | + return fmt.Errorf("kind %d is not job feedback", e.Kind) | | 501 | + } | | 502 | + return mcpJobShape(e) | | 503 | +} | | 504 | + | | 505 | +func mcpCheckJobResult(e event.Event) error { | | 506 | + if !event.IsJobResult(e.Kind) { | | 507 | + return fmt.Errorf("kind %d is not a job result", e.Kind) | | 508 | + } | | 509 | + if request := event.Tag(e, "request"); request != "" && (!json.Valid([]byte(request)) || !strings.HasPrefix(strings.TrimSpace(request), "{")) { | | 510 | + return errors.New("the request tag must carry the job request event as JSON") | | 511 | + } | | 512 | + if strings.TrimSpace(e.Content) == "" { | | 513 | + return errors.New("content must not be empty") | | 514 | + } | | 515 | + return mcpJobShape(e) | | 516 | +} | | 517 | + | | 518 | // mcpReaction accepts NIP-25 content: +, - or one emoji, which may be a | | 519 | // sequence of a few non-ASCII code points such as a flag or a skin tone. | | 520 | func mcpReaction(content string) bool { |
|
About| owner | c01503e8035f |
|---|
| visibility | public |
|---|
| head | 51a4c905ad75 |
|---|
Maintainers- c01503e8035f owner
- ba1b5beed5b5 agent hermes
Clonehttps://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git
|