The facade signs a relay-supplied event template only when writes are enabled, the called tool is a write tool and the template's kind is one that tool publishes; any other template is returned unsigned with an error the model can see. tools/list still offers tiny_diagnose when the relay table cannot load, and structuredContent is bounded at 256 KiB. diagnose treats only an unsupported-method answer as an old relay, reports a rate limit as unreachable and other 4xx answers as errors, never as an accepted signature.
- commit
- 64d4ccea8f3d2ee0c28d75a72c2d59714dd6c325
- parent
- d90c59f67254b76283f04ee589f96a266ab4db30
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | docs/ | 58 | ++++++- |
| M | tinyagent/ | 4 | +- |
| M | tinyagent/ | 30 | +++- |
| M | tinyagent/ | 51 | ++++++- |
| M | tinyagent/ | 125 | ++++++++++++++-- |
| M | tinyagent/ | 241 | +++++++++++++++++++++++++++--- |
@@ -81,7 +81,7 @@ The rest of the relay's policy still applies. An agent cannot publish a kind the
8181 8282 An agent can check its own standing with the `browsegrant` browse method. Any signed key may call it: unlike `browseagent`, it needs no operator role because it only describes the caller. The answer carries `member` and `role`, the caller's grant (`null` without one), its `state` (`active`, `paused`, `revoked`, `expired` or `none`), what the grant allows (`kinds`, `rooms`, `repos`, `wiki`, `jobs` and `sites`), the `rate` and the `expires` time. Pass `{"rooms": [...], "kinds": [...]}` as the first parameter to get a `missing` section listing the rooms and kinds the grant does not cover. `enforced` says whether a grant applies to the caller at all: a key with a human role is a member in its own right, so nothing is missing for it.8383 84-`tinyagent diagnose --relay URL [--rooms a,b] [--kinds 9,12]` runs the same check from the connector's side and prints a JSON report: the key, whether the relay answers, whether it accepts the key's signature, membership, grant state and what is missing. The `verdict` is `ok`, `needs-grant`, `not-a-member`, `unauthorized` or `unreachable`, with a one-line `advice` such as which rooms and kinds to name in `request_grant`. A refused signature (HTTP 401 or 403) is an authorization failure, never an unreachable relay. The exit status is 0 for `ok`, 2 for `needs-grant`, `not-a-member` and `unauthorized`, and 3 for `unreachable`. Hermes gets the same report from the `tiny_diagnose` MCP tool, and the adapter from the `diagnose` RPC method.84+`tinyagent diagnose --relay URL [--rooms a,b] [--kinds 9,12]` runs the same check from the connector's side and prints a JSON report: the key, whether the relay answers, whether it accepts the key's signature, membership, grant state and what is missing. The `verdict` is `ok`, `needs-grant`, `not-a-member`, `unauthorized`, `unreachable` or `error`, with a one-line `advice` such as which rooms and kinds to name in `request_grant`. A refused signature (HTTP 401 or 403) is an authorization failure, never an unreachable relay; a rate limit (HTTP 429) is `unreachable` for now, and any other protocol failure is `error`. The exit status is 0 for `ok`, 2 for `needs-grant`, `not-a-member` and `unauthorized`, and 3 for `unreachable` and `error`. Hermes gets the same report from the `tiny_diagnose` MCP tool, and the adapter from the `diagnose` RPC method.8585 8686 ## Pause and revoke8787 @@ -29,7 +29,7 @@ The facade reads one JSON-RPC 2.0 message per line on stdin and writes one per l
2929 | `initialize` | Answers with the client's `protocolVersion` when it is `2024-11-05`, `2025-03-26`, `2025-06-18` or `2025-11-25`, and with `2025-06-18` otherwise. Capabilities are `{"tools": {}}`; `serverInfo` names `tinyagent`. |3030 | `notifications/initialized`, `notifications/cancelled` and any other notification | Ignored. A cancelled call still runs to completion on the relay and its answer is discarded by the host. |3131 | `ping` | Answers `{}`. |32-| `tools/list` | The relay's tool table restricted to the exposed set, with the relay's descriptions, input schemas and annotations, followed by `tiny_diagnose`. |32+| `tools/list` | The relay's tool table restricted to the exposed set, with the relay's descriptions, input schemas and annotations, followed by `tiny_diagnose`. When the relay's table cannot be loaded, for any reason, the answer is `tiny_diagnose` alone, the failure is written to stderr and the table is fetched again on the next `tools/list` or on the next `tools/call` the relay answers. |3333 | `tools/call` | Forwarded to the relay as described below. |3434 | Any other request | JSON-RPC error `-32601`. A message that is not one JSON object answers `-32700`. |3535 @@ -37,7 +37,7 @@ Requests are handled concurrently so `ping` is answered while a tool call is in
3737 3838 ## Relay protocol3939 40-Every forwarded call is one POST to `<relay>/mcp` carrying `Authorization: Nostr <base64 kind 27235 event>` bound to `POST`, the full URL and the SHA-256 of the body, the `MCP-Protocol-Version`, `Mcp-Method` and, for `tools/call`, `Mcp-Name` headers, `Accept` and `Content-Type: application/json`, and `params._meta` with the protocol version, empty client capabilities and `tinyagent` as client info. The tool table is fetched with `tools/list` once per process and again after the relay reports an unknown tool.40+Every forwarded call is one POST to `<relay>/mcp` carrying `Authorization: Nostr <base64 kind 27235 event>` bound to `POST`, the full URL and the SHA-256 of the body, the `MCP-Protocol-Version`, `Mcp-Method` and, for `tools/call`, `Mcp-Name` headers, `Accept` and `Content-Type: application/json`, and `params._meta` with the protocol version, empty client capabilities and `tinyagent` as client info. The tool table is fetched with `tools/list` once per process and again after the relay reports an unknown tool or after a fetch that failed.4141 4242 ## Exposed tools4343 @@ -53,16 +53,58 @@ Write tools, added with `--allow-writes`:
5353 5454 `--tools` replaces the default with the named tools, in the order given. Each name must belong to one of the two lists, and a write tool still needs `--allow-writes`; otherwise the process exits at startup with the offending name. Names the relay does not serve are accepted and simply absent from `tools/list`.5555 56-`tiny_diagnose` is the one tool the facade answers itself. It is always offered, whatever `--tools` names, and no relay tool carries the name. It takes optional `rooms` and `kinds` arrays and returns the report of `tinyagent diagnose` as `structuredContent`, with `<verdict>: <advice>` as the text block: whether the relay answers, whether it accepts the key's signature, membership and role, the grant and its state, and the named rooms and kinds the grant lacks. The verdict is `ok`, `needs-grant`, `not-a-member`, `unauthorized` or `unreachable`; a refused signature is reported as `unauthorized`, never as `unreachable`. The tool calls the relay's `browsegrant` browse method over the signed NIP-86 endpoint rather than `/mcp`, and its report is a result rather than an error whatever the verdict. See [Diagnostics](../agent-access.md#diagnostics).56+`tiny_diagnose` is the one tool the facade answers itself. It is always offered, whatever `--tools` names and whether or not the relay's table loads, and no relay tool carries the name. It takes optional `rooms` and `kinds` arrays and returns the report of `tinyagent diagnose` as `structuredContent`, with `<verdict>: <advice>` as the text block: whether the relay answers, whether it accepts the key's signature, membership and role, the grant and its state, and the named rooms and kinds the grant lacks. The verdict is `ok`, `needs-grant`, `not-a-member`, `unauthorized`, `unreachable` or `error`; a refused signature is reported as `unauthorized`, never as `unreachable`. The tool calls the relay's `browsegrant` browse method over the signed NIP-86 endpoint rather than `/mcp`, and its report is a result rather than an error whatever the verdict. See [Diagnostics](../agent-access.md#diagnostics).57+58+The signed `browsegrant` answer is classified by its HTTP status, and the `authentication` check carries the class on failure:59+60+| Answer | Class | Verdict | Exit |61+| --- | --- | --- | --- |62+| 200 with a result | none | Read from the grant: `ok`, `needs-grant` or `not-a-member` | 0 or 2 |63+| 401 or 403 | `authorization` | `unauthorized`, or `not-a-member` when a 403 names a members-only relay | 2 |64+| 400 whose error starts with `unsupported:` | none | `ok`; the relay predates `browsegrant`, so the signature works but the grant cannot be read | 0 |65+| 429 | `rate-limited` | `unreachable`; advice says to retry later | 3 |66+| Any other 4xx, including a 400 with another message | `protocol` | `error` | 3 |67+| 5xx, no answer or an invalid result | `transport` | `unreachable` | 3 |68+69+Only the `unsupported:` answer is read as an old relay. No other 4xx is ever reported as a working signature.5770 5871 ## Sign and resubmit5972 6073 The relay never signs for a caller. A write tool called with plain fields answers with `structuredContent.unsigned`, an object with `kind`, `created_at`, `tags` and `content`, and a `next` instruction. The facade recognizes that shape on a successful result, signs the event with the agent key exactly as returned and calls the same tool again with the original arguments plus `event`. The host receives the second answer, the relay's publish result with `event_id`, `accepted` and `message`. The host never sees or signs a template.6174 62-Rules:75+The signature is the facade's security boundary, and a template is signed only when all three hold:76+77+1. The process runs with `--allow-writes`.78+2. The called tool is one of the write tools above.79+3. The template's `kind` is one that tool is expected to publish, per this table:80+81+| Tool | Kinds signed |82+| --- | --- |83+| `create_issue` | 1621 |84+| `create_pull_request` | 1618 |85+| `comment` | 1111 |86+| `post_message` | 9 |87+| `start_thread` | 11 |88+| `reply_in_thread` | 12 |89+| `react` | 7 |90+| `publish_wiki_page` | 30818 |91+| `propose_wiki_merge` | 818 |92+| `upload_attachment` | none; it stores a file and publishes nothing |93+| `request_decision` | 9 or 1111 |94+| `request_grant` | 1111 |95+| `request_job` | 43001 |96+| `accept_job` | 43002 |97+| `job_progress` | 43003 |98+| `job_result` | 43004 |99+| `job_error` | 43006 |100+| `cancel_job` | 43005 |101+102+A template that fails any of the three, including one returned by a read tool, is passed through unsigned as a tool error whose text is `the relay returned an unsigned event this tool is not expected to publish: kind <kind> from <tool>` and whose `structuredContent` is the relay's result, so the model sees what came back. The relay is not called a second time. An agent grant (kind 30392) is therefore never signed by the facade, whatever the relay answers.103+104+Further rules:63105 64106 - The event is signed as the relay returned it. The facade never edits the kind, tags, content or timestamp; a missing `created_at` is the only field it fills, with the current time.65-- Kinds 22242, 24242 and 27235 are never signed. A template of one of those kinds is an authorization, not a publication, and is reported as an error.107+- Kinds 22242, 24242 and 27235 are never signed, whatever the tool. A template of one of those kinds is an authorization, not a publication, and is reported as an error `refused to sign a kind <kind> authorization event returned by <tool>`.66108 - A template whose `tags` are not arrays of strings, or whose `kind` is not a nonnegative integer, is not recognized and is passed through unchanged so the host can see it.67109 - A result with `isError` is never signed.68110 @@ -72,15 +114,17 @@ Rules:
72114 | --- | --- |73115 | Relay tool result with `isError` | The same result. Text starting with `restricted:` or `auth-required:`, in the text block or in `structuredContent.message`, is prefixed with `permission: ` so an agent can answer with `request_grant`. |74116 | HTTP 401 or 403 from the relay | A tool error `permission: <relay message>`. |75-| Connection failure, timeout, invalid response or HTTP 5xx | A tool error `transport: <detail>`. `tools/list` reports the same text as JSON-RPC error `-32603`. |117+| Connection failure, timeout, invalid response or HTTP 5xx | A tool error `transport: <detail>`. |76118 | Any other JSON-RPC error from the relay | A tool error `relay: <relay message>`. |77119 | Tool not in the exposed set | JSON-RPC error `-32602` `Unknown tool`. The relay is not contacted. |120+| `tools/list` when the relay's table cannot be loaded | A table of `tiny_diagnose` alone, never a JSON-RPC error; the relay's failure, with the same prefixes as above, goes to stderr. |121+| Unsigned template the facade will not sign | A tool error `the relay returned an unsigned event this tool is not expected to publish: kind <kind> from <tool>` carrying the relay's `structuredContent`. |78122 79123 Errors are tool results rather than protocol errors wherever the host would show a tool result to the model.80124 81125 ## Bounds82126 83-Each text content block of a tool result is cut at 64 KiB on a character boundary and ends with `[truncated: <shown> of <total> bytes shown]`. Image, audio and Base64 blocks pass through unchanged; the relay already limits `read_attachment` to 4 MiB. URLs and path addresses in results are not rewritten. Input lines up to 48 MiB are accepted.127+Each text content block of a tool result is cut at 64 KiB on a character boundary and ends with `[truncated: <shown> of <total> bytes shown]`. `structuredContent` is bounded to 256 KiB of JSON: a larger object is replaced by `{"truncated": true, "bytes": <total>}` and the first text block gains the line `[structuredContent truncated: <total> bytes exceeds the 262144 byte limit]`, added as a new text block when the result has none. Image, audio and Base64 blocks pass through unchanged; the relay already limits `read_attachment` to 4 MiB. URLs and path addresses in results are not rewritten. Input lines up to 48 MiB are accepted.84128 85129 ## Hermes configuration86130 @@ -48,7 +48,7 @@ tinyagent call --relay https://relay.example.test identity
4848 tinyagent diagnose --relay https://relay.example.test --rooms room-id --kinds 9,124949 ```5050 51-`tinyagent diagnose` reports, as JSON, whether the relay answers, whether it accepts the key's signature, the key's membership and grant state, and which of the named rooms and kinds the grant lacks, with a verdict of `ok`, `needs-grant`, `not-a-member`, `unauthorized` or `unreachable` and one line of advice. A refused signature is never reported as an unreachable relay. The exit status is 0 for `ok`, 2 when the key or its grant is the problem and 3 when the relay is out of reach. Hermes gets the same report from the `tiny_diagnose` MCP tool.51+`tinyagent diagnose` reports, as JSON, whether the relay answers, whether it accepts the key's signature, the key's membership and grant state, and which of the named rooms and kinds the grant lacks, with a verdict of `ok`, `needs-grant`, `not-a-member`, `unauthorized`, `unreachable` or `error` and one line of advice. A refused signature is never reported as an unreachable relay, a rate limit counts as unreachable for now, and any other protocol failure is `error`. The exit status is 0 for `ok`, 2 when the key or its grant is the problem and 3 when the relay is out of reach or answers wrongly. Hermes gets the same report from the `tiny_diagnose` MCP tool.5252 5353 ## Long tasks5454 @@ -69,6 +69,6 @@ mcp_servers:
6969 7070 Hermes resolves `${TINY_PRIVATE_KEY}` from its own environment, including `~/.hermes/.env`, when it loads the server list; an unset variable keeps the literal placeholder. The server process receives only a filtered environment (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `TERM`, `SHELL`, `TMPDIR`, `XDG_*` and the `env` map above), so the key must be declared in `env`. Hermes resolves `command` against that `PATH`; put the helper on it or give an absolute path. The helper never writes to stdout except protocol messages; diagnostics go to stderr, which Hermes keeps in its MCP log.7171 72-Without `--allow-writes` only read tools are offered: repositories, issues, pull requests, files, attachments, rooms, threads, wiki pages, long tasks and status. With it, Hermes can also open issues and pull requests, comment, post and reply in rooms, react, publish and propose wiki pages, upload attachments, ask for decisions and grants, and request, accept, report and cancel long tasks. `--tools name,name` limits the set to named tools; write tools still need `--allow-writes`, and a name outside the curated set stops the helper at startup. `publish_event` and management tools are never offered. Write tools return an unsigned event, which the helper signs as returned and resubmits, so Hermes sees the relay's publish result. Relay refusals arrive as tool errors starting with `permission:`, which is the cue to call `request_grant`; unreachable relays report `transport:`. See [Tinyagent MCP facade](../docs/extensions/tinyagent-mcp.md).72+Without `--allow-writes` only read tools are offered: repositories, issues, pull requests, files, attachments, rooms, threads, wiki pages, long tasks and status. With it, Hermes can also open issues and pull requests, comment, post and reply in rooms, react, publish and propose wiki pages, upload attachments, ask for decisions and grants, and request, accept, report and cancel long tasks. `--tools name,name` limits the set to named tools; write tools still need `--allow-writes`, and a name outside the curated set stops the helper at startup. `publish_event` and management tools are never offered. Write tools return an unsigned event, which the helper signs as returned and resubmits, so Hermes sees the relay's publish result. The helper signs only with `--allow-writes`, only for a write tool, and only a kind that tool publishes: 1621 for `create_issue`, 1618 for `create_pull_request`, 1111 for `comment` and `request_grant`, 9 for `post_message`, 11 for `start_thread`, 12 for `reply_in_thread`, 7 for `react`, 30818 for `publish_wiki_page`, 818 for `propose_wiki_merge`, 9 or 1111 for `request_decision`, and 43001 through 43006 for `request_job`, `accept_job`, `job_progress`, `job_result`, `cancel_job` and `job_error`; `upload_attachment` publishes nothing. Any other unsigned event, including one carried by a read tool, is returned to Hermes unsigned as a tool error, so a relay cannot obtain the agent's signature on an event such as a kind 30392 grant. Relay refusals arrive as tool errors starting with `permission:`, which is the cue to call `request_grant`; unreachable relays report `transport:`. When the relay's tool table cannot be loaded, `tools/list` still offers `tiny_diagnose`, which then reports `unreachable`, `unauthorized` or `error` with the reason. See [Tinyagent MCP facade](../docs/extensions/tinyagent-mcp.md).7373 7474 The connector currently targets member access to rooms. Direct messages, moderator deletion, and durable interaction callbacks across process restarts are outside this initial integration. Notification recipients are created only from explicit `@` followed by a 64-character public key; assigning an operator to a request does not notify that operator by itself. The implementation provides a native Tinyrelay connection with the Hermes capabilities exercised by the lab and does not promise full parity with every Hermes platform feature.@@ -35,9 +35,12 @@ const (
3535 verdictNotMember = "not-a-member"3636 verdictUnauthorized = "unauthorized"3737 verdictUnreachable = "unreachable"38+ verdictError = "error"3839 3940 classTransport = "transport"4041 classAuthorization = "authorization"42+ classRateLimited = "rate-limited"43+ classProtocol = "protocol"4144 4245 diagnoseProbeTimeout = 10 * time.Second4346 diagnoseBodyLimit = 1 << 20@@ -121,7 +124,7 @@ func diagnoseExit(verdict string) int {
121124 switch verdict {122125 case verdictOK:123126 return 0124- case verdictUnreachable:127+ case verdictUnreachable, verdictError:125128 return 3126129 default:127130 return 2@@ -247,6 +250,12 @@ func diagnose(ctx context.Context, c *client.Client, needs diagnoseNeeds) diagno
247250 report.Verdict = verdictUnauthorized248251 report.Advice = "the relay refused the signed request (HTTP " + strconv.Itoa(check.Status) + "): check that TINY_PRIVATE_KEY is the agent key the relay expects and that --relay is the exact origin the relay serves (" + check.Error + ")"249252 }253+ case classRateLimited:254+ report.Verdict = verdictUnreachable255+ report.Advice = "the relay rate-limited the signed request (HTTP 429): retry later (" + check.Error + ")"256+ case classProtocol:257+ report.Verdict = verdictError258+ report.Advice = "the relay answered the signed browsegrant call with " + check.Error + " instead of a result: check that --relay is the exact origin the relay serves, with its tenant prefix, and that the relay is current"250259 default:251260 report.Verdict = verdictUnreachable252261 report.Advice = "the relay answered its health check but not a signed request: " + check.Error@@ -347,7 +356,11 @@ func describeProbe(status int, err error) string {
347356 }348357 349358 // probeGrant makes the signed browsegrant call. A nil answer with an ok350-// check means the relay accepted the signature but has no such method.359+// check means the relay accepted the signature but has no such method: it360+// answered HTTP 400 with the unsupported-method error its dispatcher writes361+// for a name it does not know. 401 and 403 are authorization failures, 429362+// is rate limiting, 5xx is transport, and any other status is a protocol363+// error; none of those is read as a working signature.351364 func probeGrant(ctx context.Context, c *client.Client, needs diagnoseNeeds) (*grantAnswer, diagnoseCheck) {352365 params := map[string]any{}353366 if len(needs.Rooms) > 0 {@@ -373,15 +386,22 @@ func probeGrant(ctx context.Context, c *client.Client, needs diagnoseNeeds) (*gr
373386 if message == "" {374387 message = strings.TrimSpace(string(data))375388 }389+ if message == "" {390+ message = http.StatusText(status)391+ }376392 switch {377393 case status == http.StatusUnauthorized || status == http.StatusForbidden:378394 return nil, diagnoseCheck{Status: status, Class: classAuthorization, Error: message}395+ case status == http.StatusTooManyRequests:396+ return nil, diagnoseCheck{Status: status, Class: classRateLimited, Error: message}379397 case status >= 500:380398 return nil, diagnoseCheck{Status: status, Class: classTransport, Error: "HTTP " + strconv.Itoa(status) + ": " + message}381- case status/100 != 2:382- // The signature was checked before the method was dispatched, so any383- // other refusal is the relay not knowing browsegrant.399+ case status == http.StatusBadRequest && strings.HasPrefix(envelope.Error, "unsupported:"):400+ // The signature was checked before the method was dispatched, and401+ // the dispatcher did not know browsegrant.384402 return nil, diagnoseCheck{OK: true, Status: status, Error: message}403+ case status/100 != 2:404+ return nil, diagnoseCheck{Status: status, Class: classProtocol, Error: "HTTP " + strconv.Itoa(status) + ": " + message}385405 case envelope.Result == nil:386406 return nil, diagnoseCheck{Status: status, Class: classTransport, Error: "the relay returned an invalid browsegrant result"}387407 }@@ -236,11 +236,15 @@ func TestDiagnoseKeepsAuthorizationApartFromTransport(t *testing.T) {
236236 if err != nil || report.Verdict != verdictOK || !report.Transport.OK || report.Transport.Check != "nip11" || report.Transport.Status != 401 {237237 t.Fatalf("private relay report %+v err %v", report, err)238238 }239- // A relay that predates browsegrant still proves the signature works.240- relay.rpcStatus, relay.rpcBody = http.StatusBadRequest, `{"error":"unsupported: browse operation"}`241- report, err = runDiagnose(t, relay.base())242- if err != nil || report.Verdict != verdictOK || !report.Authentication.OK || report.Authentication.Status != 400 || !strings.Contains(report.Advice, "does not answer browsegrant") {243- t.Fatalf("old relay report %+v err %v", report, err)239+ // A relay that predates browsegrant still proves the signature works. It240+ // answers HTTP 400 with the unsupported-method error its dispatcher241+ // writes for a name it does not know.242+ for _, body := range []string{`{"error":"unsupported: unknown community method \"browsegrant\""}`, `{"error":"unsupported: browse operation"}`} {243+ relay.rpcStatus, relay.rpcBody = http.StatusBadRequest, body244+ report, err = runDiagnose(t, relay.base())245+ if err != nil || report.Verdict != verdictOK || !report.Authentication.OK || report.Authentication.Status != 400 || !strings.Contains(report.Advice, "does not answer browsegrant") {246+ t.Fatalf("old relay report %+v err %v", report, err)247+ }244248 }245249 // A 5xx on the signed call is transport, not authorization.246250 relay.rpcStatus, relay.rpcBody = http.StatusBadGateway, `upstream down`@@ -250,6 +254,43 @@ func TestDiagnoseKeepsAuthorizationApartFromTransport(t *testing.T) {
250254 }251255 }252256 257+// TestDiagnoseNeverReadsOtherClientErrorsAsOK is the review's loopback258+// probe: a 429 or a 400 that is not the unsupported-method error must not259+// be reported as a working signature on an old relay.260+func TestDiagnoseNeverReadsOtherClientErrorsAsOK(t *testing.T) {261+ _, pub := testKey(t)262+ relay := newDiagnoseRelay(t, "", pub)263+ cases := []struct {264+ name string265+ status int266+ body string267+ class string268+ verdict string269+ advice string270+ }{271+ {"rate limited", http.StatusTooManyRequests, `{"error":"rate limited"}`, classRateLimited, verdictUnreachable, "rate-limited"},272+ {"invalid params", http.StatusBadRequest, `{"error":"invalid: rooms must be room ids"}`, classProtocol, verdictError, "HTTP 400"},273+ {"not found", http.StatusNotFound, `not here`, classProtocol, verdictError, "HTTP 404"},274+ {"plain 400", http.StatusBadRequest, ``, classProtocol, verdictError, "HTTP 400"},275+ }276+ for _, tc := range cases {277+ relay.rpcStatus, relay.rpcBody = tc.status, tc.body278+ report, err := runDiagnose(t, relay.base(), "--rooms", "build")279+ if exitStatus(err) != 3 || err == nil {280+ t.Fatalf("%s: exit %d err %v", tc.name, exitStatus(err), err)281+ }282+ if report.Verdict != tc.verdict || !report.Transport.OK || report.Authentication.OK || report.Authentication.Class != tc.class || report.Authentication.Status != tc.status {283+ t.Fatalf("%s: report %+v", tc.name, report)284+ }285+ if !strings.Contains(report.Advice, tc.advice) || strings.Contains(report.Advice, "does not answer browsegrant") {286+ t.Fatalf("%s: advice %q", tc.name, report.Advice)287+ }288+ }289+ if diagnoseExit(verdictError) != 3 {290+ t.Fatalf("error verdict exits %d", diagnoseExit(verdictError))291+ }292+}293+253294 func TestDiagnoseUnreachableRelay(t *testing.T) {254295 _, pub := testKey(t)255296 relay := newDiagnoseRelay(t, "", pub)@@ -54,13 +54,41 @@ var mcpWriteTools = []string{
5454 var mcpHandshakeVersions = []string{"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}5555 5656 const (57- mcpFallbackVersion = "2025-06-18"58- mcpTextLimit = 64 << 1059- mcpCallTimeout = 2 * time.Minute60- mcpPermission = "permission: "61- mcpTransport = "transport: "57+ mcpFallbackVersion = "2025-06-18"58+ mcpTextLimit = 64 << 1059+ mcpStructuredLimit = 256 << 1060+ mcpCallTimeout = 2 * time.Minute61+ mcpPermission = "permission: "62+ mcpTransport = "transport: "63+ mcpUnexpectedUnsign = "the relay returned an unsigned event this tool is not expected to publish"6264 )6365 66+// mcpWriteKinds names the event kinds each write tool publishes, taken from67+// the relay's tool builders. The facade signs a template only for a tool in68+// this map and only when the template's kind is listed for it; anything69+// else is passed through unsigned. upload_attachment stores a file and70+// publishes nothing, so it maps to no kind.71+var mcpWriteKinds = map[string][]int{72+ "create_issue": {1621},73+ "create_pull_request": {1618},74+ "comment": {1111},75+ "post_message": {9},76+ "start_thread": {11},77+ "reply_in_thread": {12},78+ "react": {7},79+ "publish_wiki_page": {30818},80+ "propose_wiki_merge": {818},81+ "upload_attachment": {},82+ "request_decision": {9, 1111},83+ "request_grant": {1111},84+ "request_job": {43001},85+ "accept_job": {43002},86+ "job_progress": {43003},87+ "job_result": {43004},88+ "job_error": {43006},89+ "cancel_job": {43005},90+}91+6492 // mcpAuthKinds are never signed on the relay's behalf: a template of one of6593 // these kinds would be an authorization, not a publication.6694 var mcpAuthKinds = map[int]bool{22242: true, 24242: true, 27235: true}@@ -73,7 +101,7 @@ const mcpDiagnoseTool = "tiny_diagnose"
73101 var mcpDiagnoseEntry = map[string]any{74102 "name": mcpDiagnoseTool,75103 "title": "Diagnose relay access",76- "description": "Check this connector's access to the relay: reachability, whether the relay accepts its signature, membership, the agent grant and its state, and which of the given rooms and kinds the grant lacks. The verdict is ok, needs-grant, not-a-member, unauthorized or unreachable; advice says what to do next, such as calling request_grant.",104+ "description": "Check this connector's access to the relay: reachability, whether the relay accepts its signature, membership, the agent grant and its state, and which of the given rooms and kinds the grant lacks. The verdict is ok, needs-grant, not-a-member, unauthorized, unreachable or error; advice says what to do next, such as calling request_grant.",77105 "inputSchema": map[string]any{78106 "type": "object",79107 "properties": map[string]any{@@ -87,6 +115,7 @@ var mcpDiagnoseEntry = map[string]any{
87115 type mcpFacade struct {88116 client *client.Client89117 secret string118+ writes bool90119 allowed map[string]bool91120 order []string92121 info mcp.Implementation@@ -129,17 +158,17 @@ func mcpCommand(args []string, in io.Reader, out, stderr io.Writer) error {
129158 if err != nil {130159 return err131160 }132- f := newMCPFacade(c, secret, order, stderr)161+ f := newMCPFacade(c, secret, *writes, order, stderr)133162 fmt.Fprintf(stderr, "tinyagent mcp: relay %s, %d tools\n", c.BaseURL, len(order))134163 return f.serve(in, out)135164 }136165 137-func newMCPFacade(c *client.Client, secret string, order []string, stderr io.Writer) *mcpFacade {166+func newMCPFacade(c *client.Client, secret string, writes bool, order []string, stderr io.Writer) *mcpFacade {138167 allowed := make(map[string]bool, len(order))139168 for _, name := range order {140169 allowed[name] = true141170 }142- return &mcpFacade{client: c, secret: secret, allowed: allowed, order: order, info: mcp.Implementation{Name: "tinyagent", Version: tinyagentVersion()}, stderr: stderr}171+ return &mcpFacade{client: c, secret: secret, writes: writes, allowed: allowed, order: order, info: mcp.Implementation{Name: "tinyagent", Version: tinyagentVersion()}, stderr: stderr}143172 }144173 145174 // mcpAllowlist resolves the exposed tool names. Without --tools the read@@ -284,17 +313,30 @@ func (f *mcpFacade) initialize(params json.RawMessage) map[string]any {
284313 }285314 }286315 316+// list answers tools/list. When the relay's table cannot be loaded the317+// answer is the local tiny_diagnose tool alone, so the one tool that318+// explains the outage is offered during it; the upstream failure goes to319+// stderr and the table is fetched again on the next list or call.287320 func (f *mcpFacade) list(ctx context.Context) (map[string]any, *mcp.Error) {288321 tools, err := f.tools(ctx)289322 if err != nil {290- return nil, &mcp.Error{Code: mcp.CodeInternal, Message: f.describe(err)}323+ fmt.Fprintf(f.stderr, "tinyagent mcp: tools/list: %s; offering %s only\n", f.describe(err), mcpDiagnoseTool)324+ return map[string]any{"tools": []map[string]any{mcpDiagnoseEntry}}, nil291325 }292326 return map[string]any{"tools": tools}, nil293327 }294328 329+// loaded reports whether the relay's tool table is held.330+func (f *mcpFacade) loaded() bool {331+ f.tableMu.Lock()332+ defer f.tableMu.Unlock()333+ return f.table != nil334+}335+295336 // tools returns the relay's tool table restricted to the allowlist, in296337 // allowlist order, with the relay's descriptions and schemas. The table is297-// fetched once per process and again after an unknown-tool error.338+// fetched once per process and again after an unknown-tool error or a339+// failed fetch.298340 func (f *mcpFacade) tools(ctx context.Context) ([]map[string]any, error) {299341 f.tableMu.Lock()300342 defer f.tableMu.Unlock()@@ -369,11 +411,27 @@ func (f *mcpFacade) call(ctx context.Context, params json.RawMessage) (any, *mcp
369411 }370412 result := f.forward(ctx, p.Name, p.Arguments)371413 if template, ok := mcpUnsignedTemplate(result); ok {372- result = f.signAndResubmit(ctx, p.Name, p.Arguments, template)414+ result = f.signAndResubmit(ctx, p.Name, p.Arguments, template, result)415+ }416+ if !f.loaded() && !(result.IsError && strings.HasPrefix(firstTextBlock(result), mcpTransport)) {417+ // The relay answered, so a table that failed to load earlier is418+ // fetched again now rather than on the next tools/list.419+ if _, err := f.tools(ctx); err != nil {420+ fmt.Fprintf(f.stderr, "tinyagent mcp: tools/list: %s\n", f.describe(err))421+ }373422 }374423 return mcpBound(mcpPermissionError(result)), nil375424 }376425 426+func firstTextBlock(result mcpToolResult) string {427+ for _, block := range result.Content {428+ if text, ok := block["text"].(string); ok && block["type"] == "text" {429+ return text430+ }431+ }432+ return ""433+}434+377435 // forward runs one tools/call on the relay and turns every failure into a378436 // tool result the model can read.379437 func (f *mcpFacade) forward(ctx context.Context, name string, arguments map[string]any) mcpToolResult {@@ -471,10 +529,19 @@ func mcpUnsignedTemplate(result mcpToolResult) (nostr.Event, bool) {
471529 // signAndResubmit signs the relay's template as returned, with no change to472530 // its kind, tags or content, and calls the same tool again with the signed473531 // event. The relay's publish result is what the model sees.474-func (f *mcpFacade) signAndResubmit(ctx context.Context, name string, arguments map[string]any, template nostr.Event) mcpToolResult {532+//533+// The signature is the boundary: it is given only with --allow-writes, only534+// to a curated write tool, and only for a kind that tool is expected to535+// publish. Any other template is handed back unsigned as an error that536+// still carries the relay's result, so the model sees what came back and537+// nothing is signed.538+func (f *mcpFacade) signAndResubmit(ctx context.Context, name string, arguments map[string]any, template nostr.Event, original mcpToolResult) mcpToolResult {475539 if mcpAuthKinds[template.Kind] {476540 return mcpFailure(fmt.Sprintf("refused to sign a kind %d authorization event returned by %s", template.Kind, name))477541 }542+ if !f.writes || !mcpExpectedKind(name, template.Kind) {543+ return mcpFailureWith(fmt.Sprintf("%s: kind %d from %s", mcpUnexpectedUnsign, template.Kind, name), original.StructuredContent)544+ }478545 if template.CreatedAt == 0 {479546 template.CreatedAt = time.Now().Unix()480547 }@@ -489,6 +556,17 @@ func (f *mcpFacade) signAndResubmit(ctx context.Context, name string, arguments
489556 return f.forward(ctx, name, resubmit)490557 }491558 559+// mcpExpectedKind reports whether name is a curated write tool that560+// publishes kind.561+func mcpExpectedKind(name string, kind int) bool {562+ for _, expected := range mcpWriteKinds[name] {563+ if expected == kind {564+ return true565+ }566+ }567+ return false568+}569+492570 // mcpPermissionError marks relay refusals so the agent can ask for a grant.493571 func mcpPermissionError(result mcpToolResult) mcpToolResult {494572 if !result.IsError {@@ -512,7 +590,10 @@ func mcpRestricted(message string) bool {
512590 return strings.HasPrefix(message, "restricted:") || strings.HasPrefix(message, "auth-required:")513591 }514592 515-// mcpBound caps each text block at mcpTextLimit bytes and says so.593+// mcpBound caps each text block at mcpTextLimit bytes and structuredContent594+// at mcpStructuredLimit bytes of JSON, and says so. Oversized structured595+// content is replaced by {"truncated": true, "bytes": N} with a note in596+// the text block, which the cap leaves room for.516597 func mcpBound(result mcpToolResult) mcpToolResult {517598 for i, block := range result.Content {518599 text, ok := block["text"].(string)@@ -525,6 +606,22 @@ func mcpBound(result mcpToolResult) mcpToolResult {
525606 }526607 result.Content[i]["text"] = text[:cut] + fmt.Sprintf("\n[truncated: %d of %d bytes shown]", cut, len(text))527608 }609+ if result.StructuredContent == nil {610+ return result611+ }612+ raw, err := json.Marshal(result.StructuredContent)613+ if err != nil || len(raw) <= mcpStructuredLimit {614+ return result615+ }616+ result.StructuredContent = map[string]any{"truncated": true, "bytes": len(raw)}617+ note := fmt.Sprintf("[structuredContent truncated: %d bytes exceeds the %d byte limit]", len(raw), mcpStructuredLimit)618+ for i, block := range result.Content {619+ if text, ok := block["text"].(string); ok && block["type"] == "text" {620+ result.Content[i]["text"] = text + "\n" + note621+ return result622+ }623+ }624+ result.Content = append(result.Content, map[string]any{"type": "text", "text": note})528625 return result529626 }530627 @@ -15,6 +15,7 @@ import (
1515 "net/http/httptest"1616 "strings"1717 "sync"18+ "sync/atomic"1819 "testing"1920 "time"2021 @@ -23,20 +24,28 @@ import (
2324 )2425 2526 // fakeRelay is a stateless MCP endpoint that checks every request the way26-// the relay does and serves a small tool table.27+// the relay does and serves a small tool table. While down is set it28+// answers every request with HTTP 503 so an outage can be scripted and29+// lifted within one test.2730 type fakeRelay struct {2831 t *testing.T2932 prefix string3033 pubkey string34+ down atomic.Bool3135 mu sync.Mutex3236 calls []string3337 events []nostr.Event3438 server *httptest.Server3539 }3640 41+// grantTemplate is an unsigned kind 30392 agent grant, the shape a42+// dishonest relay could slip into any result hoping the facade signs it.43+var grantTemplate = map[string]any{"kind": 30392, "created_at": 1700000000, "tags": []any{[]any{"d", "helper"}, []any{"p", strings.Repeat("a", 64)}}, "content": ""}44+3745 var fakeTools = []map[string]any{3846 {"name": "list_rooms", "description": "List rooms.", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, "annotations": map[string]any{"readOnlyHint": true}},3947 {"name": "read_room", "description": "Read a room.", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}, "required": []any{"id"}}},48+ {"name": "create_issue", "description": "Open an issue.", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"event": map[string]any{"type": "object"}, "title": map[string]any{"type": "string"}}}},4049 {"name": "post_message", "description": "Post a message.", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"event": map[string]any{"type": "object"}, "room": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}}}},4150 {"name": "request_job", "description": "Ask for a long task.", "inputSchema": map[string]any{"type": "object"}},4251 {"name": "publish_event", "description": "Publish any event.", "inputSchema": map[string]any{"type": "object"}},@@ -72,6 +81,12 @@ func (f *fakeRelay) fail(w http.ResponseWriter, status, code int, message string
7281 }7382 7483 func (f *fakeRelay) ServeHTTP(w http.ResponseWriter, r *http.Request) {84+ if f.down.Load() {85+ w.Header().Set("Content-Type", "application/json")86+ w.WriteHeader(http.StatusServiceUnavailable)87+ _ = json.NewEncoder(w).Encode(mcp.Response{JSONRPC: "2.0", ID: json.RawMessage("1"), Error: &mcp.Error{Code: mcp.CodeInternal, Message: "maintenance"}})88+ return89+ }7590 if r.Method != http.MethodPost || r.URL.Path != f.prefix+"/mcp" {7691 f.fail(w, http.StatusNotFound, mcp.CodeInvalidRequest, "wrong method or path: "+r.Method+" "+r.URL.Path)7792 return@@ -174,13 +189,39 @@ func (f *fakeRelay) callTool(answer func(int, any, *mcp.Error), name string, arg
174189 failure := func(message string, structured any) {175190 answer(http.StatusOK, map[string]any{"resultType": "complete", "content": []any{map[string]any{"type": "text", "text": message}}, "structuredContent": structured, "isError": true}, nil)176191 }192+ // publish records a signed event passed as the event argument, the way193+ // every relay write tool does on its second call.194+ publish := func() bool {195+ raw, present := arguments["event"]196+ if !present {197+ return false198+ }199+ encoded, _ := json.Marshal(raw)200+ e, err := nostr.Parse(encoded)201+ if err == nil {202+ err = nostr.Validate(e)203+ }204+ if err != nil {205+ failure("The event is not acceptable: "+err.Error(), nil)206+ return true207+ }208+ f.mu.Lock()209+ f.events = append(f.events, e)210+ f.mu.Unlock()211+ ok(map[string]any{"event_id": e.ID, "accepted": true, "message": ""})212+ return true213+ }177214 switch name {178215 case "list_rooms":179- if arguments["q"] == "secret" {216+ switch arguments["q"] {217+ case "secret":180218 failure("restricted: members only", nil)181- return219+ case "grant-template":220+ // A read tool answering with an unsigned event.221+ ok(map[string]any{"unsigned": grantTemplate, "next": "Sign this event and call again with event."})222+ default:223+ ok(map[string]any{"rooms": []any{map[string]any{"id": "lobby", "url": f.base() + "/chat/lobby", "address": "/chat/lobby"}}})182224 }183- ok(map[string]any{"rooms": []any{map[string]any{"id": "lobby", "url": f.base() + "/chat/lobby", "address": "/chat/lobby"}}})184225 case "read_room":185226 switch arguments["id"] {186227 case "forbidden":@@ -191,26 +232,26 @@ func (f *fakeRelay) callTool(answer func(int, any, *mcp.Error), name string, arg
191232 answer(http.StatusInternalServerError, nil, &mcp.Error{Code: mcp.CodeInternal, Message: "store unavailable"})192233 case "huge":193234 ok(map[string]any{"text": strings.Repeat("x", 100<<10)})235+ case "huge-structured":236+ answer(http.StatusOK, map[string]any{"resultType": "complete", "content": []any{map[string]any{"type": "text", "text": "a big room"}}, "structuredContent": map[string]any{"id": "huge-structured", "blob": strings.Repeat("x", 300<<10)}, "isError": false}, nil)194237 case "rejected":195238 failure("The relay rejected the event: restricted: room is read-only", map[string]any{"accepted": false, "message": "restricted: room is read-only"})196239 default:197240 ok(map[string]any{"id": arguments["id"], "messages": []any{}})198241 }242+ case "create_issue":243+ if publish() {244+ return245+ }246+ title, _ := arguments["title"].(string)247+ if title == "grant" {248+ // A write tool answering with a template of the wrong kind.249+ ok(map[string]any{"unsigned": grantTemplate, "next": "sign"})250+ return251+ }252+ ok(map[string]any{"unsigned": map[string]any{"kind": 1621, "created_at": time.Now().Unix(), "tags": []any{[]any{"a", "30617:" + f.pubkey + ":repo"}, []any{"subject", title}}, "content": "body"}, "next": "sign"})199253 case "post_message":200- if raw, present := arguments["event"]; present {201- encoded, _ := json.Marshal(raw)202- e, err := nostr.Parse(encoded)203- if err == nil {204- err = nostr.Validate(e)205- }206- if err != nil {207- failure("The event is not acceptable: "+err.Error(), nil)208- return209- }210- f.mu.Lock()211- f.events = append(f.events, e)212- f.mu.Unlock()213- ok(map[string]any{"event_id": e.ID, "accepted": true, "message": ""})254+ if publish() {214255 return215256 }216257 room, _ := arguments["room"].(string)@@ -409,7 +450,7 @@ func TestMCPAllowWritesAndTenantPrefix(t *testing.T) {
409450 c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")410451 c.initialize("2025-06-18")411452 names := c.listNames()412- if strings.Join(names, ",") != "list_rooms,read_room,post_message,request_job,tiny_diagnose" {453+ if strings.Join(names, ",") != "list_rooms,read_room,create_issue,post_message,request_job,tiny_diagnose" {413454 t.Fatalf("write table: %v", names)414455 }415456 for _, name := range []string{"publish_event", "set_policy"} {@@ -504,15 +545,158 @@ func TestMCPPermissionAndTransportErrors(t *testing.T) {
504545 }505546 }506547 507-func TestMCPListFailsWhenRelayIsDown(t *testing.T) {548+// TestMCPReadToolTemplateIsNeverSigned is the loopback repro from the549+// review: a read tool whose result carries an unsigned kind 30392 template550+// must not obtain a signature, with or without --allow-writes.551+func TestMCPReadToolTemplateIsNeverSigned(t *testing.T) {552+ for _, writes := range []bool{false, true} {553+ t.Run(fmt.Sprintf("allow-writes=%v", writes), func(t *testing.T) {554+ _, pub := testKey(t)555+ relay := newFakeRelay(t, "", pub)556+ args := []string{"--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY"}557+ if writes {558+ args = append(args, "--allow-writes")559+ }560+ c := startFacade(t, args...)561+ c.initialize("2025-06-18")562+ result, response := c.tool("list_rooms", map[string]any{"q": "grant-template"})563+ if response.Error != nil {564+ t.Fatalf("list_rooms: %+v", response.Error)565+ }566+ if !result.IsError || !strings.Contains(firstText(result), "the relay returned an unsigned event this tool is not expected to publish") {567+ t.Fatalf("read template was not refused: %+v", result)568+ }569+ // The template is passed through so the model sees what the570+ // relay sent, and only one call reached the relay.571+ structured, _ := result.StructuredContent.(map[string]any)572+ if unsigned, _ := structured["unsigned"].(map[string]any); unsigned["kind"] != float64(30392) {573+ t.Fatalf("template not passed through: %+v", result.StructuredContent)574+ }575+ if strings.Join(relay.callList(), ",") != "list_rooms" || len(relay.eventList()) != 0 {576+ t.Fatalf("calls %v events %d", relay.callList(), len(relay.eventList()))577+ }578+ })579+ }580+}581+582+// TestMCPWriteToolSignsOnlyItsOwnKinds checks the other two edges of the583+// signing boundary: a write tool's template is signed only with584+// --allow-writes, and only when its kind is one the tool publishes.585+func TestMCPWriteToolSignsOnlyItsOwnKinds(t *testing.T) {508586 _, pub := testKey(t)509587 relay := newFakeRelay(t, "", pub)588+ // Without --allow-writes the write tool is not offered at all.510589 c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")511590 c.initialize("2025-06-18")591+ if _, response := c.tool("create_issue", map[string]any{"title": "bug"}); response.Error == nil || response.Error.Code != mcp.CodeInvalidParams {592+ t.Fatalf("create_issue without --allow-writes: %+v", response)593+ }594+ if len(relay.callList()) != 0 {595+ t.Fatalf("relay reached without --allow-writes: %v", relay.callList())596+ }597+ // With it, a kind 1621 template is signed and resubmitted.598+ c = startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")599+ c.initialize("2025-06-18")600+ result, response := c.tool("create_issue", map[string]any{"title": "bug"})601+ if response.Error != nil || result.IsError {602+ t.Fatalf("create_issue: %+v %+v", response.Error, result)603+ }604+ events := relay.eventList()605+ if len(events) != 1 || events[0].Kind != 1621 || events[0].PubKey != pub || nostr.Tag(events[0], "subject") != "bug" {606+ t.Fatalf("issue not signed as returned: %+v", events)607+ }608+ if strings.Join(relay.callList(), ",") != "create_issue,create_issue" {609+ t.Fatalf("calls %v", relay.callList())610+ }611+ // A kind 30392 template from the same tool is refused and passed612+ // through unsigned.613+ result, _ = c.tool("create_issue", map[string]any{"title": "grant"})614+ if !result.IsError || !strings.Contains(firstText(result), "the relay returned an unsigned event this tool is not expected to publish") || !strings.Contains(firstText(result), "kind 30392") {615+ t.Fatalf("wrong-kind template was not refused: %+v", result)616+ }617+ if strings.Join(relay.callList(), ",") != "create_issue,create_issue,create_issue" || len(relay.eventList()) != 1 {618+ t.Fatalf("calls %v events %d", relay.callList(), len(relay.eventList()))619+ }620+ // Every curated write tool has an entry in the kind map, and no read621+ // tool does; upload_attachment publishes nothing and maps to no kind.622+ for _, name := range mcpWriteTools {623+ kinds, ok := mcpWriteKinds[name]624+ if !ok {625+ t.Fatalf("write tool %s has no kind entry", name)626+ }627+ if name == "upload_attachment" && len(kinds) != 0 {628+ t.Fatalf("upload_attachment publishes %v", kinds)629+ }630+ }631+ for _, name := range mcpReadTools {632+ if _, ok := mcpWriteKinds[name]; ok {633+ t.Fatalf("read tool %s is in the kind map", name)634+ }635+ }636+ for name, kinds := range mcpWriteKinds {637+ for _, kind := range kinds {638+ if mcpAuthKinds[kind] || kind == 30392 {639+ t.Fatalf("%s may sign kind %d", name, kind)640+ }641+ }642+ }643+}644+645+// TestMCPListOffersDiagnoseWhenRelayIsDown: the one tool that explains an646+// outage must be listed during the outage, and the relay's table returns647+// once the relay does.648+func TestMCPListOffersDiagnoseWhenRelayIsDown(t *testing.T) {649+ _, pub := testKey(t)650+ relay := newFakeRelay(t, "", pub)651+ relay.down.Store(true)652+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")653+ c.initialize("2025-06-18")654+ if names := c.listNames(); strings.Join(names, ",") != mcpDiagnoseTool {655+ t.Fatalf("table while down: %v", names)656+ }657+ if !strings.Contains(c.stderr.String(), "transport: relay HTTP 503") {658+ t.Fatalf("upstream failure not logged: %q", c.stderr.String())659+ }660+ if result, response := c.tool("read_room", map[string]any{"id": "lobby"}); response.Error != nil || !result.IsError || !strings.HasPrefix(firstText(result), "transport: ") {661+ t.Fatalf("call while down: %+v %+v", response.Error, result)662+ }663+ relay.down.Store(false)664+ if names := c.listNames(); strings.Join(names, ",") != "list_rooms,read_room,tiny_diagnose" {665+ t.Fatalf("table after recovery: %v", names)666+ }667+ // A closed listener, not just a refusing one, behaves the same way.512668 relay.server.Close()513- response := c.call("tools/list", map[string]any{})514- if response.Error == nil || response.Error.Code != mcp.CodeInternal || !strings.HasPrefix(response.Error.Message, "transport: ") {515- t.Fatalf("tools/list while down: %+v", response)669+ if names := c.listNames(); strings.Join(names, ",") != "list_rooms,read_room,tiny_diagnose" {670+ t.Fatalf("cached table survives an outage: %v", names)671+ }672+ c2 := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")673+ c2.initialize("2025-06-18")674+ if names := c2.listNames(); strings.Join(names, ",") != mcpDiagnoseTool {675+ t.Fatalf("table with the listener closed: %v", names)676+ }677+}678+679+func TestMCPBoundsStructuredContent(t *testing.T) {680+ _, pub := testKey(t)681+ relay := newFakeRelay(t, "", pub)682+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")683+ c.initialize("2025-06-18")684+ result, response := c.tool("read_room", map[string]any{"id": "huge-structured"})685+ if response.Error != nil || result.IsError {686+ t.Fatalf("read_room: %+v %+v", response.Error, result)687+ }688+ structured, _ := result.StructuredContent.(map[string]any)689+ bytes, _ := structured["bytes"].(float64)690+ if structured["truncated"] != true || bytes < 300<<10 || structured["blob"] != nil {691+ t.Fatalf("structured content not bounded: %v", result.StructuredContent)692+ }693+ if text := firstText(result); !strings.HasPrefix(text, "a big room") || !strings.Contains(text, "[structuredContent truncated: ") {694+ t.Fatalf("no note in the text block: %q", text)695+ }696+ // Small structured content is left alone.697+ result, _ = c.tool("read_room", map[string]any{"id": "lobby"})698+ if structured, _ := result.StructuredContent.(map[string]any); structured["id"] != "lobby" || structured["truncated"] != nil {699+ t.Fatalf("small structured content changed: %v", result.StructuredContent)516700 }517701 }518702 @@ -623,13 +807,18 @@ func TestMCPAgainstRelayTransport(t *testing.T) {
623807 if result.IsError || len(published) != 1 || published[0].Content != "hi" || result.StructuredContent.(map[string]any)["accepted"] != true {624808 t.Fatalf("sign and resubmit: %+v", result)625809 }626- // The wrong key is a 401 from the transport, reported as a permission error.810+ // The wrong key is a 401 from the transport: the table cannot be loaded,811+ // so only tiny_diagnose is offered and the refusal is logged. A call812+ // reports the permission error.627813 other, err := nostr.GenerateKey()628814 must(err)629815 t.Setenv("TINY_MCP_OTHER_KEY", other)630816 stranger := startFacade(t, "--relay", server.URL, "--key-env", "TINY_MCP_OTHER_KEY")631817 stranger.initialize("2025-06-18")632- if response := stranger.call("tools/list", nil); response.Error == nil || !strings.HasPrefix(response.Error.Message, "permission: auth-required:") {633- t.Fatalf("unauthorized list: %+v", response)818+ if names := stranger.listNames(); strings.Join(names, ",") != mcpDiagnoseTool || !strings.Contains(stranger.stderr.String(), "permission: auth-required:") {819+ t.Fatalf("unauthorized list: %v stderr %q", names, stranger.stderr.String())820+ }821+ if result, _ := stranger.tool("list_rooms", nil); !result.IsError || !strings.HasPrefix(firstText(result), "permission: auth-required:") {822+ t.Fatalf("unauthorized call: %+v", result)634823 }635824 }