browsegrant lets any signed key read its own membership, grant state and effective scope, and reports which requested rooms and kinds the grant is missing. tinyagent diagnose, its rpc method and the facade's tiny_diagnose tool probe reachability, signature acceptance, membership and grant coverage, and answer with a verdict that separates authorization failures from transport failures and advises a request_grant call when scope is missing.
- commit
- 7472b16121c5ed4ccbce9e324aba80cd86f409bb
- parent
- e5002d7633eedc71044a4d60582bf9843321b192
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 6 | + |
| M | docs/ | 4 | +- |
| A | internal/ | 171 | +++++++++++ |
| A | internal/ | 137 | +++++++++ |
| M | internal/ | 4 | +- |
| A | tinyagent/ | 475 | ++++++++++++++++++++++++++++++ |
| A | tinyagent/ | 304 | +++++++++++++++++++ |
| M | tinyagent/ | 8 | +- |
| M | tinyagent/ | 28 | ++- |
| M | tinyagent/ | 8 | +- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 2 | +- |
@@ -77,6 +77,12 @@ The request appears in Notifications and Approvals with the current grant and th
7777 7878 The rest of the relay's policy still applies. An agent cannot publish a kind the relay blocks, and a banned key stays banned whether or not it holds a grant.7979 80+### Diagnostics81+82+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.83+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.85+8086 ## Pause and revoke8187 8288 Use the NIP-86 management methods to control agents. The owner and moderators may call each method. Every call is recorded in the audit log.@@ -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. |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`. |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 @@ -53,6 +53,8 @@ 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](../agents.md#diagnostics).57+5658 ## Sign and resubmit5759 5860 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.@@ -0,0 +1,171 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "strings"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/community"12+)13+14+// browsegrant lets any signed caller see its own standing on the relay: its15+// membership and role, the agent grant recorded for its key and what that16+// grant covers. Unlike browseagent it needs no operator role, because a key17+// only ever learns about itself here. The optional needs let a connector ask18+// "may I post these kinds into these rooms" and get back what is missing.19+20+const grantNeedsMax = 25621+22+// grantNeeds is the optional first parameter of browsegrant.23+type grantNeeds struct {24+ Rooms []string `json:"rooms"`25+ Kinds []int `json:"kinds"`26+}27+28+// grantAllows is the scope of the caller's grant as the gate applies it.29+type grantAllows struct {30+ Kinds []int `json:"kinds"`31+ Rooms []string `json:"rooms"`32+ Repos []community.AgentRepo `json:"repos"`33+ Wiki string `json:"wiki"`34+ Jobs string `json:"jobs"`35+ Sites []community.AgentSite `json:"sites"`36+}37+38+// grantMissing lists the requested rooms and kinds the caller may not use.39+type grantMissing struct {40+ Rooms []string `json:"rooms"`41+ Kinds []int `json:"kinds"`42+}43+44+// grantSelf is the browsegrant result.45+type grantSelf struct {46+ Member bool `json:"member"`47+ Role string `json:"role"`48+ Grant *community.AgentSummary `json:"grant"`49+ State string `json:"state"`50+ Enforced bool `json:"enforced"`51+ Allows *grantAllows `json:"allows,omitempty"`52+ Rate int `json:"rate,omitempty"`53+ Expires int64 `json:"expires,omitempty"`54+ Missing *grantMissing `json:"missing,omitempty"`55+}56+57+// grantState names the effective state of one grant.58+func grantState(grant community.AgentGrant, now int64) string {59+ switch {60+ case grant.RevokedAt > 0:61+ return "revoked"62+ case grant.Paused:63+ return "paused"64+ case grant.ExpiresAt <= now:65+ return "expired"66+ }67+ return "active"68+}69+70+func parseGrantNeeds(params []json.RawMessage) (grantNeeds, bool, error) {71+ var needs grantNeeds72+ if len(params) == 0 || len(params[0]) == 0 || string(params[0]) == "null" {73+ return needs, false, nil74+ }75+ if err := json.Unmarshal(params[0], &needs); err != nil {76+ return needs, false, fmt.Errorf("invalid: browsegrant parameters: %w", err)77+ }78+ if len(needs.Rooms) > grantNeedsMax || len(needs.Kinds) > grantNeedsMax {79+ return needs, false, fmt.Errorf("invalid: browsegrant lists more than %d entries", grantNeedsMax)80+ }81+ for i, room := range needs.Rooms {82+ room = strings.TrimSpace(room)83+ if room == "" {84+ return needs, false, errors.New("invalid: browsegrant rooms must be room ids")85+ }86+ needs.Rooms[i] = room87+ }88+ for _, kind := range needs.Kinds {89+ if kind < 0 || kind > 65535 {90+ return needs, false, errors.New("invalid: browsegrant kinds must be event kinds")91+ }92+ }93+ return needs, len(needs.Rooms) > 0 || len(needs.Kinds) > 0, nil94+}95+96+// browseGrant returns the caller's own grant and effective state. A key97+// without a grant gets state "none". A key with a human role is a member in98+// its own right; the gate does not apply a grant to its events, so nothing99+// is reported missing for it.100+func (t *Tenant) browseGrant(ctx context.Context, actor string, params []json.RawMessage) (any, error) {101+ needs, asked, err := parseGrantNeeds(params)102+ if err != nil {103+ return nil, err104+ }105+ result := grantSelf{State: "none"}106+ if actor == "" {107+ if asked {108+ result.Missing = &grantMissing{Rooms: nonNilStrings(needs.Rooms), Kinds: nonNilInts(needs.Kinds)}109+ }110+ return result, nil111+ }112+ role, err := t.community.Role(ctx, actor)113+ if err != nil {114+ return nil, err115+ }116+ result.Role = role117+ result.Member = role != ""118+ result.Enforced = role == "" || role == "agent"119+ grant, ok, err := t.community.AgentGrant(ctx, actor)120+ if err != nil {121+ return nil, err122+ }123+ now := time.Now().Unix()124+ if ok {125+ summary := community.AgentSummary{AgentGrant: grant}126+ _ = t.store.DB().QueryRowContext(ctx, `SELECT coalesce(max(created_at),0) FROM events WHERE pubkey=?`, actor).Scan(&summary.LastEvent)127+ result.Grant = &summary128+ result.State = grantState(grant, now)129+ result.Rate = grant.Scope.Rate130+ result.Expires = grant.ExpiresAt131+ result.Allows = &grantAllows{Kinds: nonNilInts(grant.Scope.Kinds), Rooms: nonNilStrings(grant.Scope.Rooms), Repos: grant.Scope.Repos, Wiki: grant.Scope.Wiki, Jobs: grant.Scope.Jobs, Sites: grant.Scope.Sites}132+ if result.Allows.Repos == nil {133+ result.Allows.Repos = []community.AgentRepo{}134+ }135+ if result.Allows.Sites == nil {136+ result.Allows.Sites = []community.AgentSite{}137+ }138+ }139+ if !asked {140+ return result, nil141+ }142+ missing := &grantMissing{Rooms: []string{}, Kinds: []int{}}143+ if result.Enforced {144+ for _, room := range needs.Rooms {145+ if !ok || !grant.AllowsRoom(room) {146+ missing.Rooms = append(missing.Rooms, room)147+ }148+ }149+ for _, kind := range needs.Kinds {150+ if !ok || !grant.AllowsKind(kind) {151+ missing.Kinds = append(missing.Kinds, kind)152+ }153+ }154+ }155+ result.Missing = missing156+ return result, nil157+}158+159+func nonNilInts(values []int) []int {160+ if values == nil {161+ return []int{}162+ }163+ return values164+}165+166+func nonNilStrings(values []string) []string {167+ if values == nil {168+ return []string{}169+ }170+ return values171+}@@ -0,0 +1,137 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strconv"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+func browseGrantAs(t *testing.T, tenant *Tenant, actor, params string) grantSelf {15+ t.Helper()16+ var raw []json.RawMessage17+ if params != "" {18+ raw = []json.RawMessage{json.RawMessage(params)}19+ }20+ result, err := tenant.Execute(context.Background(), actor, "browsegrant", raw)21+ if err != nil {22+ t.Fatalf("browsegrant by %q: %v", actor, err)23+ }24+ self, ok := result.(grantSelf)25+ if !ok {26+ t.Fatalf("browsegrant result %T", result)27+ }28+ return self29+}30+31+func TestBrowseGrantReportsOwnGrantWithoutOperatorRole(t *testing.T) {32+ _, tenant := testTenant(t)33+ ctx := context.Background()34+ owner := tenant.Policy().Owner35+ agent, _ := event.PublicKey(testAgentSecret)36+ member, _ := event.PublicKey(testMemberSecret)37+ now := time.Now().Unix()38+ raw, _ := json.Marshal(map[string]string{"role": "member"})39+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), raw}); err != nil {40+ t.Fatal(err)41+ }42+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "9"}, []string{"k", "1"}, []string{"room", "build"}, []string{"jobs", "serve"}, []string{"rate", "30"})); err != nil {43+ t.Fatal(err)44+ }45+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now-5, nil, "hello")); err != nil {46+ t.Fatal(err)47+ }48+ needs := `{"rooms":["build","ops"],"kinds":[9,12,43002,43001,0]}`49+50+ // The agent sees its own grant, state and what its needs lack.51+ self := browseGrantAs(t, tenant, agent, needs)52+ if !self.Member || self.Role != "agent" || self.State != "active" || !self.Enforced || self.Grant == nil || self.Grant.Name != "helper" || self.Grant.LastEvent != now-5 {53+ t.Fatalf("agent self = %+v", self)54+ }55+ if self.Rate != 30 || self.Expires != now+3600 || self.Allows == nil || strings.Join(self.Allows.Rooms, ",") != "build" || self.Allows.Jobs != "serve" || len(self.Allows.Kinds) != 2 || self.Allows.Kinds[0] != 9 {56+ t.Fatalf("agent allows = %+v rate %d expires %d", self.Allows, self.Rate, self.Expires)57+ }58+ if self.Missing == nil || strings.Join(self.Missing.Rooms, ",") != "ops" || len(self.Missing.Kinds) != 2 || self.Missing.Kinds[0] != 12 || self.Missing.Kinds[1] != 43001 {59+ t.Fatalf("agent missing = %+v", self.Missing)60+ }61+ if encoded, _ := json.Marshal(self); !strings.Contains(string(encoded), `"grant":{`) || !strings.Contains(string(encoded), `"allows":{`) {62+ t.Fatalf("wire shape %s", encoded)63+ }64+65+ // Without needs there is no missing section.66+ if self := browseGrantAs(t, tenant, agent, ""); self.Missing != nil || self.State != "active" {67+ t.Fatalf("agent without needs = %+v", self)68+ }69+70+ // A human member has no grant and the gate does not apply one, so nothing71+ // is missing for it.72+ self = browseGrantAs(t, tenant, member, needs)73+ if !self.Member || self.Role != "member" || self.State != "none" || self.Grant != nil || self.Allows != nil || self.Enforced {74+ t.Fatalf("member self = %+v", self)75+ }76+ if self.Missing == nil || len(self.Missing.Rooms) != 0 || len(self.Missing.Kinds) != 0 {77+ t.Fatalf("member missing = %+v", self.Missing)78+ }79+ if encoded, _ := json.Marshal(self); !strings.Contains(string(encoded), `"grant":null`) || strings.Contains(string(encoded), `"allows"`) {80+ t.Fatalf("member wire shape %s", encoded)81+ }82+83+ // A stranger is not a member and lacks everything it asked for.84+ stranger := strings.Repeat("e", 64)85+ self = browseGrantAs(t, tenant, stranger, needs)86+ if self.Member || self.Role != "" || self.State != "none" || self.Grant != nil || !self.Enforced {87+ t.Fatalf("stranger self = %+v", self)88+ }89+ if self.Missing == nil || strings.Join(self.Missing.Rooms, ",") != "build,ops" || len(self.Missing.Kinds) != 5 {90+ t.Fatalf("stranger missing = %+v", self.Missing)91+ }92+93+ // The owner may still not use it to read another key's grant: the94+ // method only ever describes the caller.95+ if self := browseGrantAs(t, tenant, owner, `{"agent":"`+agent+`"}`); self.Role != "owner" || self.Grant != nil {96+ t.Fatalf("owner self = %+v", self)97+ }98+99+ // Paused and revoked grants are reported as such.100+ if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {101+ t.Fatal(err)102+ }103+ if self := browseGrantAs(t, tenant, agent, needs); self.State != "paused" || !self.Member || len(self.Missing.Rooms) != 1 {104+ t.Fatalf("paused self = %+v", self)105+ }106+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {107+ t.Fatal(err)108+ }109+ if self := browseGrantAs(t, tenant, agent, ""); self.State != "revoked" || self.Member || self.Role != "" {110+ t.Fatalf("revoked self = %+v", self)111+ }112+113+ // Bad needs are refused.114+ for _, bad := range []string{`{"kinds":[-1]}`, `{"rooms":[""]}`, `[1]`} {115+ if _, err := tenant.Execute(ctx, agent, "browsegrant", []json.RawMessage{json.RawMessage(bad)}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {116+ t.Fatalf("browsegrant %s: %v", bad, err)117+ }118+ }119+}120+121+func TestBrowseGrantReportsExpiredGrants(t *testing.T) {122+ _, tenant := testTenant(t)123+ agent, _ := event.PublicKey(testAgentSecret)124+ now := time.Now().Unix()125+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now-10, now+60, []string{"k", "9"})); err != nil {126+ t.Fatal(err)127+ }128+ if self := browseGrantAs(t, tenant, agent, ""); self.State != "active" {129+ t.Fatalf("fresh grant = %+v", self)130+ }131+ if _, err := tenant.store.DB().Exec(`UPDATE agent_grants SET expires_at=? WHERE agent=?`, now-1, agent); err != nil {132+ t.Fatal(err)133+ }134+ if self := browseGrantAs(t, tenant, agent, `{"kinds":[9]}`); self.State != "expired" || self.Grant == nil || len(self.Missing.Kinds) != 0 {135+ t.Fatalf("expired grant = %+v", self)136+ }137+}@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
3434 }3535 3636 func clientBrowseMethod(method string) bool {37- return method == "browsechatactivity" || wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)37+ return method == "browsechatactivity" || wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browsegrant", "browseprofile"}, method)3838 }3939 4040 func (t *Tenant) browseRead(ctx context.Context, actor string) error {@@ -98,6 +98,8 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
9898 return t.browseStatus(ctx, actor)9999 case "browseagent":100100 return t.browseAgent(ctx, actor, q.Agent)101+ case "browsegrant":102+ return t.browseGrant(ctx, actor, params)101103 case "browseprofile":102104 return t.browseProfile(ctx, actor, q.Pubkey)103105 case "browseissues":@@ -0,0 +1,475 @@
1+package main2+3+import (4+ "bytes"5+ "context"6+ "crypto/rand"7+ "crypto/sha256"8+ "encoding/base64"9+ "encoding/hex"10+ "encoding/json"11+ "errors"12+ "flag"13+ "fmt"14+ "io"15+ "net/http"16+ "os"17+ "strconv"18+ "strings"19+ "time"20+21+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"22+ "github.com/FelineStateMachine/tinyrelay/tinyagent/client"23+)24+25+// Diagnostics answer one question for an agent and its operator: can this26+// key do what the connector needs on this relay, and if not, is the relay27+// out of reach, is the key refused, or is the grant too narrow. Transport28+// failures and authorization failures are kept apart throughout: a signed29+// request the relay answers, whatever the status, proves the relay is30+// reachable.31+32+const (33+ verdictOK = "ok"34+ verdictNeedsGrant = "needs-grant"35+ verdictNotMember = "not-a-member"36+ verdictUnauthorized = "unauthorized"37+ verdictUnreachable = "unreachable"38+39+ classTransport = "transport"40+ classAuthorization = "authorization"41+42+ diagnoseProbeTimeout = 10 * time.Second43+ diagnoseBodyLimit = 1 << 2044+)45+46+// diagnoseNeeds names the rooms and kinds the connector wants to use.47+type diagnoseNeeds struct {48+ Rooms []string `json:"rooms"`49+ Kinds []int `json:"kinds"`50+}51+52+// diagnoseCheck is one probe's outcome. Class is set on failure only.53+type diagnoseCheck struct {54+ OK bool `json:"ok"`55+ Check string `json:"check,omitempty"`56+ Status int `json:"status,omitempty"`57+ Class string `json:"class,omitempty"`58+ Error string `json:"error,omitempty"`59+}60+61+// diagnoseMissing lists what the grant lacks among the needs.62+type diagnoseMissing struct {63+ Rooms []string `json:"rooms"`64+ Kinds []int `json:"kinds"`65+}66+67+// diagnosis is the report written by `tinyagent diagnose`, the diagnose68+// RPC method and the tiny_diagnose MCP tool.69+type diagnosis struct {70+ Relay string `json:"relay"`71+ PubKey string `json:"pubkey"`72+ Transport diagnoseCheck `json:"transport"`73+ Authentication diagnoseCheck `json:"authentication"`74+ Member bool `json:"member"`75+ Role string `json:"role"`76+ Grant json.RawMessage `json:"grant"`77+ State string `json:"state"`78+ Enforced bool `json:"enforced"`79+ Allows json.RawMessage `json:"allows,omitempty"`80+ Rate int `json:"rate,omitempty"`81+ Expires int64 `json:"expires,omitempty"`82+ Needs *diagnoseNeeds `json:"needs,omitempty"`83+ Missing *diagnoseMissing `json:"missing,omitempty"`84+ Verdict string `json:"verdict"`85+ Advice string `json:"advice"`86+}87+88+// grantAnswer is the relay's browsegrant result.89+type grantAnswer struct {90+ Member bool `json:"member"`91+ Role string `json:"role"`92+ Grant json.RawMessage `json:"grant"`93+ State string `json:"state"`94+ Enforced bool `json:"enforced"`95+ Allows json.RawMessage `json:"allows"`96+ Rate int `json:"rate"`97+ Expires int64 `json:"expires"`98+ Missing *diagnoseMissing `json:"missing"`99+}100+101+// exitError carries a process exit status through run.102+type exitError struct {103+ code int104+ message string105+}106+107+func (e *exitError) Error() string { return e.message }108+109+// exitStatus is the process status for an error from run: 1 unless the110+// error names its own code.111+func exitStatus(err error) int {112+ var exit *exitError113+ if errors.As(err, &exit) {114+ return exit.code115+ }116+ return 1117+}118+119+// diagnoseExit maps a verdict to the documented exit status.120+func diagnoseExit(verdict string) int {121+ switch verdict {122+ case verdictOK:123+ return 0124+ case verdictUnreachable:125+ return 3126+ default:127+ return 2128+ }129+}130+131+func diagnoseCommand(args []string, out io.Writer) error {132+ fs := flag.NewFlagSet("diagnose", flag.ContinueOnError)133+ fs.SetOutput(io.Discard)134+ relay := fs.String("relay", "", "relay URL")135+ keyEnv := fs.String("key-env", "TINY_PRIVATE_KEY", "private key environment variable")136+ rooms := fs.String("rooms", "", "comma-separated room ids the connector needs")137+ kinds := fs.String("kinds", "", "comma-separated event kinds the connector needs")138+ if err := fs.Parse(args); err != nil {139+ return err140+ }141+ if *relay == "" {142+ return errors.New("usage: tinyagent diagnose --relay URL [--key-env TINY_PRIVATE_KEY] [--rooms a,b] [--kinds 9,12]")143+ }144+ secret := os.Getenv(*keyEnv)145+ if secret == "" {146+ return fmt.Errorf("%s is not set", *keyEnv)147+ }148+ c, err := client.New(*relay, secret)149+ if err != nil {150+ return err151+ }152+ needs, err := parseNeedsFlags(*rooms, *kinds)153+ if err != nil {154+ return err155+ }156+ report := diagnose(context.Background(), c, needs)157+ encoder := json.NewEncoder(out)158+ encoder.SetIndent("", " ")159+ if err := encoder.Encode(report); err != nil {160+ return err161+ }162+ if code := diagnoseExit(report.Verdict); code != 0 {163+ return &exitError{code: code, message: report.Verdict + ": " + report.Advice}164+ }165+ return nil166+}167+168+func parseNeedsFlags(rooms, kinds string) (diagnoseNeeds, error) {169+ var needs diagnoseNeeds170+ for _, room := range strings.Split(rooms, ",") {171+ if room = strings.TrimSpace(room); room != "" {172+ needs.Rooms = append(needs.Rooms, room)173+ }174+ }175+ for _, raw := range strings.Split(kinds, ",") {176+ raw = strings.TrimSpace(raw)177+ if raw == "" {178+ continue179+ }180+ kind, err := strconv.Atoi(raw)181+ if err != nil || kind < 0 || kind > 65535 {182+ return needs, fmt.Errorf("--kinds: %q is not an event kind", raw)183+ }184+ needs.Kinds = append(needs.Kinds, kind)185+ }186+ return needs, nil187+}188+189+// parseNeedsParams reads rooms and kinds from RPC params or tool arguments.190+func parseNeedsParams(raw map[string]json.RawMessage) (diagnoseNeeds, error) {191+ var needs diagnoseNeeds192+ if len(raw["rooms"]) > 0 {193+ if err := json.Unmarshal(raw["rooms"], &needs.Rooms); err != nil {194+ return needs, errors.New("rooms must be an array of room ids")195+ }196+ }197+ if len(raw["kinds"]) > 0 {198+ if err := json.Unmarshal(raw["kinds"], &needs.Kinds); err != nil {199+ return needs, errors.New("kinds must be an array of event kinds")200+ }201+ }202+ for _, room := range needs.Rooms {203+ if strings.TrimSpace(room) == "" {204+ return needs, errors.New("rooms must be room ids")205+ }206+ }207+ for _, kind := range needs.Kinds {208+ if kind < 0 || kind > 65535 {209+ return needs, errors.New("kinds must be event kinds")210+ }211+ }212+ return needs, nil213+}214+215+// diagnose runs the probes in order and stops at the first one that rules216+// out the rest: an unreachable relay is not asked to authenticate, and a217+// refused key is not asked about its grant.218+func (s *rpcServer) diagnose(ctx context.Context, raw map[string]json.RawMessage) (any, error) {219+ needs, err := parseNeedsParams(raw)220+ if err != nil {221+ return nil, err222+ }223+ return diagnose(ctx, s.client, needs), nil224+}225+226+func diagnose(ctx context.Context, c *client.Client, needs diagnoseNeeds) diagnosis {227+ report := diagnosis{Relay: c.BaseURL, PubKey: c.PubKey, Grant: json.RawMessage("null"), State: "none"}228+ if len(needs.Rooms) > 0 || len(needs.Kinds) > 0 {229+ copied := diagnoseNeeds{Rooms: append([]string{}, needs.Rooms...), Kinds: append([]int{}, needs.Kinds...)}230+ report.Needs = &copied231+ }232+ report.Transport = probeTransport(ctx, c)233+ if !report.Transport.OK {234+ report.Verdict = verdictUnreachable235+ report.Advice = "the relay at " + c.BaseURL + " did not answer: check the URL, the network and that the relay is running (" + report.Transport.Error + ")"236+ return report237+ }238+ answer, check := probeGrant(ctx, c, needs)239+ report.Authentication = check240+ if !check.OK {241+ switch check.Class {242+ case classAuthorization:243+ if check.Status == http.StatusForbidden && strings.Contains(check.Error, "members-only") {244+ report.Verdict = verdictNotMember245+ report.Advice = "the relay is members-only and this key is not a member: ask the owner for membership or an agent grant" + needsClause(needs)246+ } else {247+ report.Verdict = verdictUnauthorized248+ 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 + ")"249+ }250+ default:251+ report.Verdict = verdictUnreachable252+ report.Advice = "the relay answered its health check but not a signed request: " + check.Error253+ }254+ return report255+ }256+ if answer == nil {257+ // The signature was accepted but the relay predates browsegrant.258+ report.Verdict = verdictOK259+ report.Advice = "the relay accepts this key's signature but does not answer browsegrant, so its grant cannot be read: upgrade the relay to see grant state"260+ return report261+ }262+ report.Member, report.Role, report.State, report.Enforced = answer.Member, answer.Role, answer.State, answer.Enforced263+ report.Rate, report.Expires = answer.Rate, answer.Expires264+ if len(answer.Grant) > 0 {265+ report.Grant = answer.Grant266+ }267+ if len(answer.Allows) > 0 && string(answer.Allows) != "null" {268+ report.Allows = answer.Allows269+ }270+ if answer.Missing != nil {271+ report.Missing = answer.Missing272+ } else if report.Needs != nil {273+ report.Missing = &diagnoseMissing{Rooms: []string{}, Kinds: []int{}}274+ }275+ report.Verdict, report.Advice = grantVerdict(answer, needs)276+ return report277+}278+279+// grantVerdict reads a browsegrant answer against the needs.280+func grantVerdict(answer *grantAnswer, needs diagnoseNeeds) (string, string) {281+ if !answer.Member {282+ return verdictNotMember, "this key is not a member of the relay: ask the owner for an agent grant" + needsClause(needs)283+ }284+ if !answer.Enforced {285+ return verdictOK, "this key holds the " + answer.Role + " role; agent grants do not apply to it"286+ }287+ switch answer.State {288+ case "paused":289+ return verdictNeedsGrant, "the agent grant is paused: ask the operator to resume it"290+ case "revoked":291+ return verdictNeedsGrant, "the agent grant is revoked: ask the operator for a new grant" + needsClause(needs)292+ case "expired":293+ return verdictNeedsGrant, "the agent grant expired at " + time.Unix(answer.Expires, 0).UTC().Format(time.RFC3339) + ": ask the operator for a new grant" + needsClause(needs)294+ case "none":295+ return verdictNeedsGrant, "this key is a member but holds no agent grant: ask the operator for one" + needsClause(needs)296+ }297+ if answer.Missing != nil && (len(answer.Missing.Rooms) > 0 || len(answer.Missing.Kinds) > 0) {298+ return verdictNeedsGrant, "call request_grant with " + missingClause(*answer.Missing)299+ }300+ return verdictOK, "the grant covers what was asked"301+}302+303+func needsClause(needs diagnoseNeeds) string {304+ if len(needs.Rooms) == 0 && len(needs.Kinds) == 0 {305+ return ""306+ }307+ return " covering " + missingClause(diagnoseMissing(needs))308+}309+310+func missingClause(missing diagnoseMissing) string {311+ parts := []string{}312+ if len(missing.Rooms) > 0 {313+ parts = append(parts, "rooms "+strings.Join(missing.Rooms, ", "))314+ }315+ if len(missing.Kinds) > 0 {316+ kinds := make([]string, len(missing.Kinds))317+ for i, kind := range missing.Kinds {318+ kinds[i] = strconv.Itoa(kind)319+ }320+ parts = append(parts, "kinds "+strings.Join(kinds, ", "))321+ }322+ return strings.Join(parts, " and ")323+}324+325+// probeTransport asks the relay whether it is up without a signature. The326+// readiness route answers first; a relay that lacks it or is not ready is327+// still reachable when its NIP-11 document answers with any status below328+// 500, including 401 on a private relay.329+func probeTransport(ctx context.Context, c *client.Client) diagnoseCheck {330+ status, _, err := diagnoseGet(ctx, c, "/readyz", "application/json")331+ if err == nil && status/100 == 2 {332+ return diagnoseCheck{OK: true, Check: "readyz", Status: status}333+ }334+ readyz := describeProbe(status, err)335+ status, _, err = diagnoseGet(ctx, c, "/", "application/nostr+json")336+ if err == nil && status < 500 {337+ return diagnoseCheck{OK: true, Check: "nip11", Status: status}338+ }339+ return diagnoseCheck{OK: false, Check: "nip11", Status: status, Class: classTransport, Error: "readyz: " + readyz + "; nip11: " + describeProbe(status, err)}340+}341+342+func describeProbe(status int, err error) string {343+ if err != nil {344+ return err.Error()345+ }346+ return "HTTP " + strconv.Itoa(status)347+}348+349+// probeGrant makes the signed browsegrant call. A nil answer with an ok350+// check means the relay accepted the signature but has no such method.351+func probeGrant(ctx context.Context, c *client.Client, needs diagnoseNeeds) (*grantAnswer, diagnoseCheck) {352+ params := map[string]any{}353+ if len(needs.Rooms) > 0 {354+ params["rooms"] = needs.Rooms355+ }356+ if len(needs.Kinds) > 0 {357+ params["kinds"] = needs.Kinds358+ }359+ body, err := json.Marshal(map[string]any{"method": "browsegrant", "params": []any{params}})360+ if err != nil {361+ return nil, diagnoseCheck{Class: classTransport, Error: err.Error()}362+ }363+ status, data, err := diagnoseSigned(ctx, c, "/manage/rpc", body)364+ if err != nil {365+ return nil, diagnoseCheck{Class: classTransport, Error: err.Error()}366+ }367+ var envelope struct {368+ Result *grantAnswer `json:"result"`369+ Error string `json:"error"`370+ }371+ _ = json.Unmarshal(data, &envelope)372+ message := envelope.Error373+ if message == "" {374+ message = strings.TrimSpace(string(data))375+ }376+ switch {377+ case status == http.StatusUnauthorized || status == http.StatusForbidden:378+ return nil, diagnoseCheck{Status: status, Class: classAuthorization, Error: message}379+ case status >= 500:380+ 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.384+ return nil, diagnoseCheck{OK: true, Status: status, Error: message}385+ case envelope.Result == nil:386+ return nil, diagnoseCheck{Status: status, Class: classTransport, Error: "the relay returned an invalid browsegrant result"}387+ }388+ return envelope.Result, diagnoseCheck{OK: true, Status: status}389+}390+391+func diagnoseGet(ctx context.Context, c *client.Client, p, accept string) (int, []byte, error) {392+ ctx, cancel := context.WithTimeout(ctx, diagnoseProbeTimeout)393+ defer cancel()394+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+p, nil)395+ if err != nil {396+ return 0, nil, err397+ }398+ req.Header.Set("Accept", accept)399+ return diagnoseDo(c, req)400+}401+402+// diagnoseSigned posts a NIP-86 body with a fresh NIP-98 proof bound to the403+// method, the full URL and the body hash, the same binding the client uses.404+func diagnoseSigned(ctx context.Context, c *client.Client, p string, body []byte) (int, []byte, error) {405+ ctx, cancel := context.WithTimeout(ctx, diagnoseProbeTimeout)406+ defer cancel()407+ target := c.BaseURL + p408+ nonce := make([]byte, 12)409+ if _, err := rand.Read(nonce); err != nil {410+ return 0, nil, fmt.Errorf("generate request nonce: %w", err)411+ }412+ sum := sha256.Sum256(body)413+ proof := nostr.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", target}, {"method", http.MethodPost}, {"nonce", hex.EncodeToString(nonce)}, {"payload", hex.EncodeToString(sum[:])}}}414+ if err := nostr.Sign(&proof, c.Secret); err != nil {415+ return 0, nil, err416+ }417+ canonical, err := nostr.Canonical(proof)418+ if err != nil {419+ return 0, nil, err420+ }421+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))422+ if err != nil {423+ return 0, nil, err424+ }425+ req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(canonical))426+ req.Header.Set("Content-Type", "application/nostr+json+rpc")427+ req.Header.Set("Accept", "application/json")428+ return diagnoseDo(c, req)429+}430+431+func diagnoseDo(c *client.Client, req *http.Request) (int, []byte, error) {432+ httpClient := c.HTTP433+ if httpClient == nil {434+ httpClient = http.DefaultClient435+ }436+ resp, err := httpClient.Do(req)437+ if err != nil {438+ return 0, nil, err439+ }440+ defer resp.Body.Close()441+ data, err := io.ReadAll(io.LimitReader(resp.Body, diagnoseBodyLimit))442+ if err != nil {443+ return resp.StatusCode, nil, err444+ }445+ return resp.StatusCode, data, nil446+}447+448+// diagnoseSummary is the one-line text form of a report for tool output.449+func diagnoseSummary(report diagnosis) string {450+ return report.Verdict + ": " + report.Advice451+}452+453+// diagnose answers the tiny_diagnose tool: the report as structured content454+// with its verdict and advice as the text block. The report is an answer,455+// not a failure, whatever the verdict.456+func (f *mcpFacade) diagnose(ctx context.Context, arguments map[string]any) mcpToolResult {457+ raw := make(map[string]json.RawMessage, len(arguments))458+ for key, value := range arguments {459+ encoded, err := json.Marshal(value)460+ if err != nil {461+ return mcpFailure(key + " is not JSON")462+ }463+ raw[key] = encoded464+ }465+ needs, err := parseNeedsParams(raw)466+ if err != nil {467+ return mcpFailure(err.Error())468+ }469+ f.relayMu.Lock()470+ defer f.relayMu.Unlock()471+ ctx, cancel := context.WithTimeout(ctx, mcpCallTimeout)472+ defer cancel()473+ report := diagnose(ctx, f.client, needs)474+ return mcpToolResult{Content: []map[string]any{{"type": "text", "text": diagnoseSummary(report)}}, StructuredContent: report}475+}@@ -0,0 +1,304 @@
1+package main2+3+import (4+ "bytes"5+ "context"6+ "crypto/sha256"7+ "encoding/base64"8+ "encoding/hex"9+ "encoding/json"10+ "errors"11+ "io"12+ "net/http"13+ "net/http/httptest"14+ "strings"15+ "sync"16+ "testing"17+18+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"19+ "github.com/FelineStateMachine/tinyrelay/tinyagent/client"20+)21+22+// diagnoseRelay serves the three routes diagnose probes: readiness, the23+// NIP-11 document and the signed browsegrant call. Each answer is scripted.24+type diagnoseRelay struct {25+ t *testing.T26+ pubkey string27+ prefix string28+ readyz int29+ nip11 int30+ rpcStatus int31+ rpcBody string32+ server *httptest.Server33+ mu sync.Mutex34+ requests []string35+ params string36+}37+38+func newDiagnoseRelay(t *testing.T, prefix, pubkey string) *diagnoseRelay {39+ t.Helper()40+ d := &diagnoseRelay{t: t, pubkey: pubkey, prefix: prefix, readyz: http.StatusOK, nip11: http.StatusOK, rpcStatus: http.StatusOK, rpcBody: `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{"pubkey":"` + pubkey + `","name":"helper"},"allows":{"kinds":[9],"rooms":["build"],"repos":[],"wiki":"","jobs":"","sites":[]},"rate":60,"expires":4102444800,"missing":{"rooms":[],"kinds":[]}}}`}41+ d.server = httptest.NewServer(d)42+ t.Cleanup(d.server.Close)43+ return d44+}45+46+func (d *diagnoseRelay) base() string { return d.server.URL + d.prefix }47+48+func (d *diagnoseRelay) seen() []string {49+ d.mu.Lock()50+ defer d.mu.Unlock()51+ return append([]string(nil), d.requests...)52+}53+54+func (d *diagnoseRelay) ServeHTTP(w http.ResponseWriter, r *http.Request) {55+ d.mu.Lock()56+ d.requests = append(d.requests, r.Method+" "+r.URL.Path)57+ d.mu.Unlock()58+ w.Header().Set("Content-Type", "application/json")59+ switch {60+ case r.Method == http.MethodGet && r.URL.Path == d.prefix+"/readyz":61+ if d.readyz == 0 {62+ http.NotFound(w, r)63+ return64+ }65+ w.WriteHeader(d.readyz)66+ _, _ = io.WriteString(w, `{"status":"ready"}`)67+ case r.Method == http.MethodGet && r.URL.Path == d.prefix+"/" && strings.Contains(r.Header.Get("Accept"), "application/nostr+json"):68+ w.WriteHeader(d.nip11)69+ _, _ = io.WriteString(w, `{"name":"test"}`)70+ case r.Method == http.MethodPost && r.URL.Path == d.prefix+"/manage/rpc":71+ body, _ := io.ReadAll(r.Body)72+ if !strings.Contains(r.Header.Get("Content-Type"), "application/nostr+json+rpc") {73+ w.WriteHeader(http.StatusBadRequest)74+ _, _ = io.WriteString(w, `{"error":"invalid: content type"}`)75+ return76+ }77+ if err := d.checkProof(r, body); err != nil {78+ w.WriteHeader(http.StatusUnauthorized)79+ _, _ = io.WriteString(w, `{"error":"auth-required: `+err.Error()+`"}`)80+ return81+ }82+ var call struct {83+ Method string `json:"method"`84+ Params []json.RawMessage `json:"params"`85+ }86+ if err := json.Unmarshal(body, &call); err != nil || call.Method != "browsegrant" {87+ w.WriteHeader(http.StatusBadRequest)88+ _, _ = io.WriteString(w, `{"error":"invalid: expected browsegrant"}`)89+ return90+ }91+ d.mu.Lock()92+ if len(call.Params) > 0 {93+ d.params = string(call.Params[0])94+ }95+ d.mu.Unlock()96+ w.WriteHeader(d.rpcStatus)97+ _, _ = io.WriteString(w, d.rpcBody)98+ default:99+ http.NotFound(w, r)100+ }101+}102+103+func (d *diagnoseRelay) checkProof(r *http.Request, body []byte) error {104+ header := r.Header.Get("Authorization")105+ if !strings.HasPrefix(header, "Nostr ") {106+ return errors.New("missing Nostr authorization")107+ }108+ raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, "Nostr "))109+ if err != nil {110+ return err111+ }112+ e, err := nostr.Parse(raw)113+ if err != nil {114+ return err115+ }116+ if err := nostr.Validate(e); err != nil {117+ return err118+ }119+ sum := sha256.Sum256(body)120+ switch {121+ case e.Kind != 27235:122+ return errors.New("wrong kind")123+ case e.PubKey != d.pubkey:124+ return errors.New("wrong signer")125+ case nostr.Tag(e, "u") != d.server.URL+r.URL.RequestURI():126+ return errors.New("u tag " + nostr.Tag(e, "u"))127+ case nostr.Tag(e, "method") != http.MethodPost:128+ return errors.New("method tag")129+ case nostr.Tag(e, "payload") != hex.EncodeToString(sum[:]):130+ return errors.New("payload tag")131+ }132+ return nil133+}134+135+func runDiagnose(t *testing.T, relay string, extra ...string) (diagnosis, error) {136+ t.Helper()137+ var out bytes.Buffer138+ args := append([]string{"diagnose", "--relay", relay, "--key-env", "TINY_MCP_TEST_KEY"}, extra...)139+ err := run(args, strings.NewReader(""), &out, io.Discard)140+ var report diagnosis141+ if out.Len() > 0 {142+ if decodeErr := json.Unmarshal(out.Bytes(), &report); decodeErr != nil {143+ t.Fatalf("diagnose output %s: %v", out.String(), decodeErr)144+ }145+ }146+ return report, err147+}148+149+func TestDiagnoseReportsMissingGrantEntries(t *testing.T) {150+ _, pub := testKey(t)151+ relay := newDiagnoseRelay(t, "/r/acme", pub)152+ relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{"pubkey":"` + pub + `","name":"helper"},"allows":{"kinds":[9],"rooms":["build"],"repos":[],"wiki":"","jobs":"","sites":[]},"rate":60,"expires":4102444800,"missing":{"rooms":["ops"],"kinds":[12]}}}`153+ report, err := runDiagnose(t, relay.base(), "--rooms", "build, ops", "--kinds", "9,12")154+ if exitStatus(err) != 2 || err == nil || !strings.HasPrefix(err.Error(), "needs-grant: call request_grant") {155+ t.Fatalf("exit %d err %v", exitStatus(err), err)156+ }157+ if report.Verdict != verdictNeedsGrant || report.Advice != "call request_grant with rooms ops and kinds 12" {158+ t.Fatalf("verdict %s advice %q", report.Verdict, report.Advice)159+ }160+ if report.PubKey != pub || report.Relay != relay.base() || !report.Transport.OK || report.Transport.Check != "readyz" || !report.Authentication.OK || report.Authentication.Status != 200 {161+ t.Fatalf("probes %+v", report)162+ }163+ if !report.Member || report.Role != "agent" || report.State != "active" || !report.Enforced || report.Rate != 60 || string(report.Grant) == "null" || len(report.Allows) == 0 {164+ t.Fatalf("grant fields %+v", report)165+ }166+ if report.Needs == nil || strings.Join(report.Needs.Rooms, ",") != "build,ops" || report.Missing == nil || strings.Join(report.Missing.Rooms, ",") != "ops" || len(report.Missing.Kinds) != 1 || report.Missing.Kinds[0] != 12 {167+ t.Fatalf("needs %+v missing %+v", report.Needs, report.Missing)168+ }169+ if relay.params != `{"kinds":[9,12],"rooms":["build","ops"]}` {170+ t.Fatalf("relay received params %s", relay.params)171+ }172+ if got := strings.Join(relay.seen(), ","); got != "GET /r/acme/readyz,POST /r/acme/manage/rpc" {173+ t.Fatalf("requests %s", got)174+ }175+}176+177+func TestDiagnoseOKThroughRPCMethod(t *testing.T) {178+ secret, pub := testKey(t)179+ relay := newDiagnoseRelay(t, "", pub)180+ c, err := client.New(relay.base(), secret)181+ if err != nil {182+ t.Fatal(err)183+ }184+ server := &rpcServer{client: c, subs: map[string]context.CancelFunc{}, sem: make(chan struct{}, 1)}185+ result, err := server.dispatch(context.Background(), "diagnose", json.RawMessage(`{"rooms":["build"],"kinds":[9]}`), io.Discard)186+ if err != nil {187+ t.Fatal(err)188+ }189+ report := result.(diagnosis)190+ if report.Verdict != verdictOK || report.Advice != "the grant covers what was asked" || report.Missing == nil || len(report.Missing.Rooms) != 0 {191+ t.Fatalf("report %+v", report)192+ }193+ if _, err := server.dispatch(context.Background(), "diagnose", json.RawMessage(`{"kinds":[-3]}`), io.Discard); err == nil {194+ t.Fatal("bad kinds accepted")195+ }196+ // A human member is never held to a grant.197+ relay.rpcBody = `{"result":{"member":true,"role":"member","state":"none","enforced":false,"grant":null}}`198+ if report, err := runDiagnose(t, relay.base(), "--kinds", "9"); err != nil || report.Verdict != verdictOK || !strings.Contains(report.Advice, "member role") {199+ t.Fatalf("member report %+v err %v", report, err)200+ }201+ // A member without a grant, and paused, revoked and expired grants, need202+ // the operator.203+ for _, state := range []string{"none", "paused", "revoked", "expired"} {204+ relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"` + state + `","enforced":true,"grant":null,"expires":1700000000}}`205+ report, err := runDiagnose(t, relay.base())206+ if exitStatus(err) != 2 || report.Verdict != verdictNeedsGrant || !strings.Contains(report.Advice, state) && !strings.Contains(report.Advice, "no agent grant") {207+ t.Fatalf("%s: report %+v err %v", state, report, err)208+ }209+ }210+}211+212+func TestDiagnoseKeepsAuthorizationApartFromTransport(t *testing.T) {213+ _, pub := testKey(t)214+ relay := newDiagnoseRelay(t, "", pub)215+ relay.rpcStatus, relay.rpcBody = http.StatusUnauthorized, `{"error":"auth-required: sign this request"}`216+ report, err := runDiagnose(t, relay.base(), "--rooms", "build")217+ if exitStatus(err) != 2 || report.Verdict != verdictUnauthorized || !report.Transport.OK || report.Authentication.OK || report.Authentication.Class != classAuthorization || report.Authentication.Status != 401 {218+ t.Fatalf("401 report %+v err %v", report, err)219+ }220+ if !strings.Contains(report.Advice, "HTTP 401") || !strings.Contains(report.Authentication.Error, "auth-required") {221+ t.Fatalf("401 advice %q error %q", report.Advice, report.Authentication.Error)222+ }223+ relay.rpcStatus, relay.rpcBody = http.StatusForbidden, `{"error":"restricted: this relay is members-only"}`224+ report, err = runDiagnose(t, relay.base(), "--rooms", "build")225+ if exitStatus(err) != 2 || report.Verdict != verdictNotMember || !report.Transport.OK || report.Authentication.Class != classAuthorization || !strings.Contains(report.Advice, "covering rooms build") {226+ t.Fatalf("403 report %+v err %v", report, err)227+ }228+ relay.rpcStatus, relay.rpcBody = http.StatusOK, `{"result":{"member":false,"role":"","state":"none","enforced":true,"grant":null,"missing":{"rooms":["build"],"kinds":[]}}}`229+ if report, err := runDiagnose(t, relay.base(), "--rooms", "build"); exitStatus(err) != 2 || report.Verdict != verdictNotMember || report.Member {230+ t.Fatalf("stranger report %+v err %v", report, err)231+ }232+ // A private relay answers its NIP-11 document with 401: reachable.233+ relay.readyz, relay.nip11 = 0, http.StatusUnauthorized234+ relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{},"missing":{"rooms":[],"kinds":[]}}}`235+ report, err = runDiagnose(t, relay.base(), "--rooms", "build")236+ if err != nil || report.Verdict != verdictOK || !report.Transport.OK || report.Transport.Check != "nip11" || report.Transport.Status != 401 {237+ t.Fatalf("private relay report %+v err %v", report, err)238+ }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)244+ }245+ // A 5xx on the signed call is transport, not authorization.246+ relay.rpcStatus, relay.rpcBody = http.StatusBadGateway, `upstream down`247+ report, err = runDiagnose(t, relay.base())248+ if exitStatus(err) != 3 || report.Verdict != verdictUnreachable || report.Authentication.Class != classTransport || !report.Transport.OK {249+ t.Fatalf("502 report %+v err %v", report, err)250+ }251+}252+253+func TestDiagnoseUnreachableRelay(t *testing.T) {254+ _, pub := testKey(t)255+ relay := newDiagnoseRelay(t, "", pub)256+ base := relay.base()257+ relay.server.Close()258+ report, err := runDiagnose(t, base, "--kinds", "9")259+ if exitStatus(err) != 3 || err == nil || !strings.HasPrefix(err.Error(), "unreachable: ") {260+ t.Fatalf("exit %d err %v", exitStatus(err), err)261+ }262+ if report.Verdict != verdictUnreachable || report.Transport.OK || report.Transport.Class != classTransport || report.Transport.Error == "" || report.Authentication.OK || report.Authentication.Class != "" {263+ t.Fatalf("report %+v", report)264+ }265+ if report.PubKey != pub || report.Needs == nil || report.Needs.Kinds[0] != 9 {266+ t.Fatalf("identity and needs %+v", report)267+ }268+ if _, err := runDiagnose(t, base, "--kinds", "nine"); err == nil || exitStatus(err) != 1 {269+ t.Fatalf("bad kinds flag: %v", err)270+ }271+ if err := run([]string{"diagnose"}, strings.NewReader(""), io.Discard, io.Discard); err == nil || !strings.HasPrefix(err.Error(), "usage: tinyagent diagnose") {272+ t.Fatalf("usage: %v", err)273+ }274+}275+276+func TestMCPDiagnoseToolAnswersWithoutTheRelayTable(t *testing.T) {277+ _, pub := testKey(t)278+ relay := newDiagnoseRelay(t, "/r/acme", pub)279+ relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{"name":"helper"},"allows":{"kinds":[9],"rooms":["build"],"repos":[],"wiki":"","jobs":"","sites":[]},"missing":{"rooms":[],"kinds":[20001]}}}`280+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")281+ c.initialize("2025-06-18")282+ result, response := c.tool(mcpDiagnoseTool, map[string]any{"rooms": []any{"build"}, "kinds": []any{9, 20001}})283+ if response.Error != nil || result.IsError {284+ t.Fatalf("tiny_diagnose: %+v %+v", response.Error, result)285+ }286+ if firstText(result) != "needs-grant: call request_grant with kinds 20001" {287+ t.Fatalf("summary %q", firstText(result))288+ }289+ structured, _ := result.StructuredContent.(map[string]any)290+ if structured["verdict"] != verdictNeedsGrant || structured["pubkey"] != pub || structured["state"] != "active" {291+ t.Fatalf("structured %v", structured)292+ }293+ if relay.params != `{"kinds":[9,20001],"rooms":["build"]}` {294+ t.Fatalf("relay received params %s", relay.params)295+ }296+ if result, _ := c.tool(mcpDiagnoseTool, map[string]any{"kinds": "nine"}); !result.IsError || !strings.Contains(firstText(result), "kinds must be") {297+ t.Fatalf("bad arguments: %+v", result)298+ }299+ for _, path := range relay.seen() {300+ if strings.HasSuffix(path, "/mcp") {301+ t.Fatalf("diagnosis touched the MCP endpoint: %v", relay.seen())302+ }303+ }304+}@@ -46,12 +46,12 @@ type rpcServer struct {
4646 func main() {4747 if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {4848 fmt.Fprintln(os.Stderr, err)49- os.Exit(1)49+ os.Exit(exitStatus(err))5050 }5151 }5252 func run(args []string, in io.Reader, out, stderr io.Writer) error {5353 if len(args) == 0 {54- return errors.New("usage: tinyagent keygen | rpc | call | mcp")54+ return errors.New("usage: tinyagent keygen | rpc | call | mcp | diagnose")5555 }5656 switch args[0] {5757 case "keygen":@@ -62,6 +62,8 @@ func run(args []string, in io.Reader, out, stderr io.Writer) error {
6262 return call(args[1:], out)6363 case "mcp":6464 return mcpCommand(args[1:], in, out, stderr)65+ case "diagnose":66+ return diagnoseCommand(args[1:], out)6567 default:6668 return fmt.Errorf("unknown command %q", args[0])6769 }@@ -218,6 +220,8 @@ func (s *rpcServer) dispatch(ctx context.Context, method string, raw json.RawMes
218220 return nil, err219221 }220222 return s.client.Download(ctx, path)223+ case "diagnose":224+ return s.diagnose(ctx, p)221225 case "unsubscribe":222226 var name string223227 if err := json.Unmarshal(p["subscription"], &name); err != nil {@@ -23,8 +23,9 @@ import (
2323 2424 // The facade speaks the standard MCP stdio protocol to Hermes and forwards2525 // every tool to the relay's stateless HTTP endpoint with fresh NIP-9826-// signatures. Only the curated tools below are ever exposed; publish_event27-// and the management tools never are.26+// signatures. Only the curated tools below are ever exposed, plus27+// tiny_diagnose, which the facade answers itself; publish_event and the28+// management tools never are.2829 2930 // mcpReadTools are exposed by default.3031 var mcpReadTools = []string{@@ -64,6 +65,25 @@ const (
6465 // these kinds would be an authorization, not a publication.6566 var mcpAuthKinds = map[int]bool{22242: true, 24242: true, 27235: true}6667 68+// mcpDiagnoseTool is the one tool the facade answers itself. It is always69+// offered, whatever the allowlist, because it is how an agent finds out why70+// the other tools fail. No relay tool carries this name.71+const mcpDiagnoseTool = "tiny_diagnose"72+73+var mcpDiagnoseEntry = map[string]any{74+ "name": mcpDiagnoseTool,75+ "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.",77+ "inputSchema": map[string]any{78+ "type": "object",79+ "properties": map[string]any{80+ "rooms": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Room ids the connector needs to post in."},81+ "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "description": "Event kinds the connector needs to publish."},82+ },83+ },84+ "annotations": map[string]any{"readOnlyHint": true, "idempotentHint": true},85+}86+6787 type mcpFacade struct {6888 client *client.Client6989 secret string@@ -314,6 +334,7 @@ func (f *mcpFacade) tools(ctx context.Context) ([]map[string]any, error) {
314334 }315335 table = append(table, entry)316336 }337+ table = append(table, mcpDiagnoseEntry)317338 f.table = table318339 return table, nil319340 }@@ -337,6 +358,9 @@ func (f *mcpFacade) call(ctx context.Context, params json.RawMessage) (any, *mcp
337358 if p.Name == "" {338359 return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "params.name is required"}339360 }361+ if p.Name == mcpDiagnoseTool {362+ return f.diagnose(ctx, p.Arguments), nil363+ }340364 if !f.allowed[p.Name] {341365 return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "Unknown tool: " + p.Name}342366 }@@ -370,7 +370,7 @@ func TestMCPInitializeAndFilteredList(t *testing.T) {
370370 t.Fatalf("ping: %+v", ping)371371 }372372 names := c.listNames()373- if strings.Join(names, ",") != "list_rooms,read_room" {373+ if strings.Join(names, ",") != "list_rooms,read_room,tiny_diagnose" {374374 t.Fatalf("read-only table: %v", names)375375 }376376 response := c.call("tools/list", map[string]any{})@@ -409,7 +409,7 @@ func TestMCPAllowWritesAndTenantPrefix(t *testing.T) {
409409 c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")410410 c.initialize("2025-06-18")411411 names := c.listNames()412- if strings.Join(names, ",") != "list_rooms,read_room,post_message,request_job" {412+ if strings.Join(names, ",") != "list_rooms,read_room,post_message,request_job,tiny_diagnose" {413413 t.Fatalf("write table: %v", names)414414 }415415 for _, name := range []string{"publish_event", "set_policy"} {@@ -521,7 +521,7 @@ func TestMCPToolsFlag(t *testing.T) {
521521 relay := newFakeRelay(t, "", pub)522522 c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--tools", "read_room, list_rooms,list_wiki")523523 c.initialize("2025-06-18")524- if names := c.listNames(); strings.Join(names, ",") != "read_room,list_rooms" {524+ if names := c.listNames(); strings.Join(names, ",") != "read_room,list_rooms,tiny_diagnose" {525525 t.Fatalf("--tools table: %v", names)526526 }527527 for _, tc := range []struct{ tools, want string }{@@ -604,7 +604,7 @@ func TestMCPAgainstRelayTransport(t *testing.T) {
604604 t.Cleanup(server.Close)605605 c := startFacade(t, "--relay", server.URL, "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")606606 c.initialize("2025-03-26")607- if names := c.listNames(); strings.Join(names, ",") != "list_rooms,post_message" {607+ if names := c.listNames(); strings.Join(names, ",") != "list_rooms,post_message,tiny_diagnose" {608608 t.Fatalf("table: %v", names)609609 }610610 result, response := c.tool("list_rooms", map[string]any{})@@ -116,7 +116,7 @@ var remoteReadMethods = map[string]bool{
116116 "browsewikipage": true, "browsewikimerge": true, "browsestatus": true,117117 "browserooms": true, "browseroom": true, "browsethread": true,118118 "browseissues": true, "browseissue": true, "browsepulls": true, "browsepull": true,119- "browseagent": true, "browsejobs": true, "browsejob": true,119+ "browseagent": true, "browsegrant": true, "browsejobs": true, "browsejob": true,120120 "browsesocial": true, "browsesocialthread": true, "browsepodcasts": true, "browsedirectmessages": true,121121 }122122 @@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
2626 return2727 }2828 }29- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browseprofile": true, "browseapprovals": true, "browseapproval": true, "browsejobs": true, "browsejob": true}29+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browsegrant": true, "browseprofile": true, "browseapprovals": true, "browseapproval": true, "browsejobs": true, "browsejob": true}3030 if !allowed[method] {3131 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})3232 return