clone

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

fix: classify site snapshots separately from long tasks

Dami committed 7282499
Apply site admission rules to kind 5128 and keep snapshots out of job reads, request builders and MCP input schemas.

Preserve relay write policy and agent grant checks. Document the supported request kinds and current NIP-5A source.

Verify classification boundaries, permissions, job reads, MCP schema exclusions and end-to-end site snapshot conformance.
commit
728249910fc8cccbc04ace016fd68ef9898076ad
parent
ba72f13dcda6d7ae3c8a61124f12e5e1648cda31
author
Dami <dami.etoile@gmail.com>
12 files changed, +202 -21
Mdocs/agents.md6+-
Mdocs/mcp.md6+-
Minternal/daemon/jobs_browse.go6++-
Minternal/daemon/jobs_test.go2+-
Minternal/daemon/mcp_tools.go10++--
Ainternal/daemon/site_snapshot_jobs_test.go50++++++++++++++++++++++++
Minternal/event/kinds.go12++++--
Ainternal/event/kinds_test.go30++++++++++++++
Minternal/gates/jobs.go4+-
Ainternal/gates/site_snapshot_test.go63++++++++++++++++++++++++++++++
Minternal/mcp/mcp_test.go27+++++++++++++
Minternal/mcp/tools.go7+++-
M docs/agents.md +3 -3
@@ -162,7 +162,7 @@ A long task is work that takes longer than one exchange: a transcription, a summ
162162 163163 ### The flow164164 165-1. A person or an agent publishes a job request, an event of kind 5000 to 5999. The kind names the type of work. The request may carry `i` tags for its inputs, an `output` tag for the expected MIME type, `param` tags, a `bid` in millisats, `relays` where answers should go, `p` tags for the providers it prefers and an `expiration`.165+1. A person or an agent publishes a job request, an event of kind 5000 to 5127 or 5129 to 5999. The kind names the type of work. The request may carry `i` tags for its inputs, an `output` tag for the expected MIME type, `param` tags, a `bid` in millisats, `relays` where answers should go, `p` tags for the providers it prefers and an `expiration`.166166 2. A serving agent answers with job feedback, kind 7000, as often as it likes. Feedback names the request in an `e` tag and the requester in a `p` tag, and carries a `status` tag of `payment-required`, `processing`, `error`, `success` or `partial`, with optional extra text, an `amount` in millisats with an optional invoice, and a sample of the output in the content.167167 3. When the work is done, the agent publishes the result, an event of the request kind plus 1000. It names the request and the requester the same way, carries the request as JSON in a `request` tag with the request's inputs, and holds the output in its content.168168
@@ -183,7 +183,7 @@ An agent takes part through its grant. A `k` tag admits one kind, as for any oth
183183 184184 | Value | Lets the agent publish |185185 | --- | --- |186-| `request` | Job requests, kinds 5000 to 5999. |186+| `request` | Job requests, kinds 5000 to 5127 or 5129 to 5999. |187187 | `serve` | Job results, kinds 6000 to 6999, and job feedback, kind 7000, in answer to requests the relay holds. |188188 | `both` | Both. |189189
@@ -213,7 +213,7 @@ A result, or feedback that reports `error` or `payment-required`, wakes the requ
213213 214214 ## Static sites215215 216-An agent can publish a static site and upload the files behind it, with the owner deciding how long that work lives and whether the files must be encrypted. The site is a [NIP-5A](https://github.com/nostr-protocol/nips/pull/2004) manifest under the agent's own key: kind 15128 for the key's site, whose label is the agent's `npub`, or kind 35128 for a named site, whose label is the key in base36 followed by the name. The files are blobs the agent uploads to the relay's file store.216+An agent can publish a static site and upload the files behind it, with the owner deciding how long that work lives and whether the files must be encrypted. The site is a [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md) manifest under the agent's own key: kind 15128 for the key's site, whose label is the agent's `npub`, or kind 35128 for a named site, whose label is the key in base36 followed by the name. The files are blobs the agent uploads to the relay's file store.217217 218218 ### The grant219219
M docs/mcp.md +3 -3
@@ -116,7 +116,7 @@ Write tools, which publish through the same path as `POST /events`:
116116 | `publish_site` | Publish a static site manifest, kind 15128 for the key's own site or kind 35128 for a named site, from `paths` given as `[path, sha256]` pairs and an optional `expiration`. |117117 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |118118 | `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. |119-| `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. |119+| `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5127 or 5129 to 5999 carrying its inputs, output type, parameters, bid and relays. |120120 | `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. |121121 | `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. |122122
@@ -136,7 +136,7 @@ The person answers with a kind 7 reaction to the published event from the asked
136136 137137 ### Static sites138138 139-`publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/pull/2004) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).139+`publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).140140 141141 ### Room attachments142142
@@ -152,7 +152,7 @@ Room uploads use `/media/<hash>.<extension>` URLs compatible with Buzz. Reads fo
152152 153153 ### Long tasks154154 155-`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.155+`request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5127 or 5129 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.156156 157157 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).158158
M internal/daemon/jobs_browse.go +4 -2
M internal/daemon/jobs_test.go +1 -1
M internal/daemon/mcp_tools.go +5 -5
@@ -41,7 +41,7 @@ var (
4141 mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}4242 mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}4343 mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}44- mcpJobKind = map[string]any{"type": "integer", "minimum": event.KIND_JOB_REQUEST_MIN, "maximum": event.KIND_JOB_REQUEST_MAX, "description": "NIP-90 job request kind, 5000 to 5999."}44+ mcpJobKind = map[string]any{"type": "integer", "minimum": event.KIND_JOB_REQUEST_MIN, "maximum": event.KIND_JOB_REQUEST_MAX, "not": map[string]any{"enum": []int{event.KIND_SITE_SNAPSHOT}}, "description": "NIP-90 job request kind, 5000 to 5127 or 5129 to 5999."}4545 mcpMsats = map[string]any{"type": "integer", "minimum": 0, "description": "Amount in millisats."}4646 mcpJobStatus = map[string]any{"type": "string", "enum": event.JobFeedbackStatuses}4747 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."}
@@ -71,7 +71,7 @@ const (
7171 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.`7272 mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`7373 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.`74- 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.`74+ mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 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.`7575 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.`7676 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.`7777 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.`
@@ -230,7 +230,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
230230 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))231231 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))232232 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))233- 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))233+ add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind (5000 to 5127 or 5129 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))234234 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))235235 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))236236 return registry, err
@@ -914,7 +914,7 @@ func mcpCheckRequest(e event.Event) error {
914914 func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {915915 kind := call.Int("kind")916916 if !event.IsJobRequest(kind) {917- return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5999")917+ return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999")918918 }919919 tags := [][]string{}920920 if inputs, ok := call.Arguments["inputs"].([]any); ok {
@@ -1026,7 +1026,7 @@ func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) {
10261026 err = json.Unmarshal(encoded, &request)10271027 }10281028 if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) {1029- return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5999")1029+ return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5127 or 5129 to 5999")10301030 }10311031 hasRequest = true10321032 }
A internal/daemon/site_snapshot_jobs_test.go +50 -0
@@ -0,0 +1,50 @@
1+package daemon2+3+import (4+ "context"5+ "strings"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"11+ "github.com/FelineStateMachine/tinyrelay/internal/sites"12+)13+14+func TestSiteSnapshotStaysOutOfJobReads(t *testing.T) {15+ _, tenant := testTenant(t)16+ ctx := context.Background()17+ now := time.Now().Unix()18+ owner := tenant.Policy().Owner19+ paths := [][]string{{"path", "/index.html", strings.Repeat("a", 64)}}20+ tags := append(paths, []string{"x", sites.Aggregate(paths), "aggregate"}, []string{"a", "15128:" + owner + ":"})21+ snapshot := wikiPublish(t, tenant, wikiOwnerSecret, event.KIND_SITE_SNAPSHOT, now, "", tags...)22+ job := wikiPublish(t, tenant, wikiOwnerSecret, 5001, now-1, "", []string{"i", "hello", "text"})23+24+ result, err := tenant.browseJobs(ctx, owner, jobBrowseRequest{Limit: 1})25+ if err != nil {26+ t.Fatal(err)27+ }28+ items := result.(map[string]any)["items"].([]jobItem)29+ if len(items) != 1 || items[0].ID != job.ID {30+ t.Fatalf("job listing = %+v; want request %s", items, job.ID)31+ }32+ if _, err := tenant.browseJob(ctx, owner, snapshot.ID); err == nil || !strings.Contains(err.Error(), "not found: job request") {33+ t.Fatalf("snapshot job read error = %v", err)34+ }35+ if _, err := tenant.browseJob(ctx, owner, job.ID); err != nil {36+ t.Fatalf("job read: %v", err)37+ }38+}39+40+func TestSiteSnapshotCannotBuildJobRequest(t *testing.T) {41+ if err := mcp.Validate(mcpJobKind, float64(event.KIND_SITE_SNAPSHOT)); err == nil {42+ t.Fatal("site snapshot kind accepted by the advertised job schema")43+ }44+ if _, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{"kind": float64(event.KIND_SITE_SNAPSHOT)}}); err == nil {45+ t.Fatal("site snapshot kind accepted by the job request builder")46+ }47+ if err := mcpCheckJobRequest(event.Event{Kind: event.KIND_SITE_SNAPSHOT}); err == nil {48+ t.Fatal("site snapshot accepted by the signed job request checker")49+ }50+}
M internal/event/kinds.go +8 -4
A internal/event/kinds_test.go +30 -0
M internal/gates/jobs.go +2 -2
A internal/gates/site_snapshot_test.go +63 -0
@@ -0,0 +1,63 @@
1+package gates2+3+import (4+ "context"5+ "strconv"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/policy"11+ "github.com/FelineStateMachine/tinyrelay/internal/relay"12+ "github.com/FelineStateMachine/tinyrelay/internal/sites"13+)14+15+func validSiteSnapshot(t *testing.T, secret, source string, createdAt int64) event.Event {16+ t.Helper()17+ hash := strings.Repeat("a", 64)18+ paths := [][]string{{"path", "/index.html", hash}}19+ e := event.Event{20+ Kind: sites.KindSiteSnapshot,21+ CreatedAt: createdAt,22+ Tags: append(paths,23+ []string{"x", sites.Aggregate(paths), "aggregate"},24+ []string{"a", "15128:" + source + ":"},25+ ),26+ }27+ if err := event.Sign(&e, secret); err != nil {28+ t.Fatal(err)29+ }30+ if err := sites.ValidateManifest(e); err != nil {31+ t.Fatalf("snapshot fixture is invalid: %v", err)32+ }33+ return e34+}35+36+func TestSiteSnapshotIsNotControlledByJobFeature(t *testing.T) {37+ f := newAgentFixture(t)38+ ctx := context.Background()39+ snapshot := validSiteSnapshot(t, agentOtherSecret, f.owner, agentNow)40+ session := relay.Session{PubKeys: []string{snapshot.PubKey}}41+42+ for _, jobsEnabled := range []bool{true, false} {43+ t.Run("jobs-enabled="+strconv.FormatBool(jobsEnabled), func(t *testing.T) {44+ p := policy.Defaults(f.owner)45+ p.Features.Jobs = jobsEnabled46+ f.gate.cfg.Policy = func() policy.Policy { return p }47+ if err := f.gate.Write(ctx, snapshot, session, agentNow); err != nil {48+ t.Fatalf("valid site snapshot rejected with jobs=%t: %v", jobsEnabled, err)49+ }50+ })51+ }52+}53+54+func TestSiteSnapshotRespectsRelayWritePolicy(t *testing.T) {55+ f := newAgentFixture(t)56+ p := policy.Defaults(f.owner)57+ p.Writes = "allowlist"58+ p.Features.Jobs = false59+ f.gate.cfg.Policy = func() policy.Policy { return p }60+ snapshot := validSiteSnapshot(t, agentOtherSecret, f.owner, agentNow)61+ err := f.gate.Write(context.Background(), snapshot, relay.Session{PubKeys: []string{snapshot.PubKey}}, agentNow)62+ expectError(t, err, "blocked:", "relay write policy")63+}
M internal/mcp/mcp_test.go +27 -0
M internal/mcp/tools.go +6 -1