| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "time" |
| 9 | "unicode/utf8" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 14 | ) |
| 15 | |
| 16 | const mcpGrantRequestShape = `Expected a signed kind 1111 NIP-22 event with matching E/K/P and e/k tags for the current kind 30392 grant, one p tag for its operator, request=grant and a grant JSON tag containing the current grant id and requested scope changes.` |
| 17 | |
| 18 | func mcpGrantRequestSchema() map[string]any { |
| 19 | text := map[string]any{"type" : "string" , "minLength" : 1} |
| 20 | repo := mcp.Object(map[string]any{ |
| 21 | "owner" : mcpPubKey, "identifier" : text, |
| 22 | "level" : map[string]any{"type" : "string" , "enum" : []string{community.AgentRepoPropose, community.AgentRepoRead, community.AgentRepoMaintain}}, |
| 23 | }, "owner" , "identifier" , "level" ) |
| 24 | site := mcp.Object(map[string]any{"label" : text, "ttl" : map[string]any{"type" : "integer" , "minimum" : 1, "maximum" : community.AgentSiteTTLMax}, "encrypted" : map[string]any{"type" : "boolean" }}, "label" ) |
| 25 | changes := mcp.Object(map[string]any{ |
| 26 | "kinds" : map[string]any{"type" : "array" , "items" : map[string]any{"type" : "integer" , "minimum" : 0, "maximum" : 65535}}, |
| 27 | "rooms" : map[string]any{"type" : "array" , "items" : text}, "repos" : map[string]any{"type" : "array" , "items" : repo}, |
| 28 | "sites" : map[string]any{"type" : "array" , "items" : site}, "wiki" : text, "jobs" : text, |
| 29 | "rate" : map[string]any{"type" : "integer" , "minimum" : 1, "maximum" : community.AgentRateMax}, |
| 30 | }) |
| 31 | return mcp.Object(map[string]any{"event" : mcpEvent, "reason" : map[string]any{"type" : "string" , "minLength" : 1, "maxLength" : 500}, "changes" : changes}) |
| 32 | } |
| 33 | |
| 34 | func (t *Tenant) mcpGrantRequest(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 35 | if raw, present := call.Arguments["event" ]; present && raw != nil { |
| 36 | encoded, err := json.Marshal(raw) |
| 37 | if err != nil { |
| 38 | return mcp.Result{}, err |
| 39 | } |
| 40 | e, err := event.Parse(encoded) |
| 41 | if err == nil && e.PubKey != call.Actor { |
| 42 | err = errors.New("the event pubkey must match the authenticated agent" ) |
| 43 | } |
| 44 | if err == nil { |
| 45 | _, err = t.community.ReviewAgentGrantRequest(ctx, e, time.Now().Unix()) |
| 46 | } |
| 47 | if err != nil { |
| 48 | return mcp.Failure("The grant request is not acceptable: " +err.Error()+"\n" +mcpGrantRequestShape, map[string]any{"expected" : mcpGrantRequestShape}), nil |
| 49 | } |
| 50 | return t.mcpPublish(ctx, call, e), nil |
| 51 | } |
| 52 | unsigned, err := t.mcpBuildGrantRequest(ctx, call) |
| 53 | if err != nil { |
| 54 | return mcp.Failure(err.Error()+"\n" +mcpGrantRequestShape, map[string]any{"expected" : mcpGrantRequestShape}), nil |
| 55 | } |
| 56 | return mcp.Value(map[string]any{"unsigned" : unsigned, "next" : mcpSignNext}), nil |
| 57 | } |
| 58 | |
| 59 | func (t *Tenant) mcpBuildGrantRequest(ctx context.Context, call mcp.Call) (mcpUnsigned, error) { |
| 60 | reason := strings.TrimSpace(call.String("reason" )) |
| 61 | if reason == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 500 { |
| 62 | return mcpUnsigned{}, errors.New("reason must be 1 to 500 valid UTF-8 characters" ) |
| 63 | } |
| 64 | grant, ok, err := t.community.AgentGrant(ctx, call.Actor) |
| 65 | if err != nil { |
| 66 | return mcpUnsigned{}, err |
| 67 | } |
| 68 | if !ok || !grant.Active(time.Now().Unix()) { |
| 69 | return mcpUnsigned{}, errors.New("only an active granted agent can request more permissions" ) |
| 70 | } |
| 71 | changes, err := mcpGrantChanges(call.Arguments["changes" ]) |
| 72 | if err != nil { |
| 73 | return mcpUnsigned{}, err |
| 74 | } |
| 75 | grantJSON, err := json.Marshal(community.AgentGrantRequest{Base: grant.EventID, Changes: changes}) |
| 76 | if err != nil { |
| 77 | return mcpUnsigned{}, err |
| 78 | } |
| 79 | tags := [][]string{{"E" , grant.EventID, "" , grant.Owner}, {"K" , "30392" }, {"P" , grant.Owner}, {"e" , grant.EventID, "" , grant.Owner}, {"k" , "30392" }, {"request" , "grant" }, {"grant" , string(grantJSON)}, {"p" , grant.Owner}} |
| 80 | unsigned := mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: reason} |
| 81 | request := event.Event{PubKey: call.Actor, Kind: unsigned.Kind, CreatedAt: unsigned.CreatedAt, Tags: unsigned.Tags, Content: unsigned.Content} |
| 82 | if _, err := t.community.ReviewAgentGrantRequest(ctx, request, time.Now().Unix()); err != nil { |
| 83 | return mcpUnsigned{}, err |
| 84 | } |
| 85 | return unsigned, nil |
| 86 | } |
| 87 | |
| 88 | func mcpGrantChanges(raw any) (community.AgentGrantChanges, error) { |
| 89 | encoded, err := json.Marshal(raw) |
| 90 | if err != nil { |
| 91 | return community.AgentGrantChanges{}, errors.New("changes must be an object" ) |
| 92 | } |
| 93 | var changes community.AgentGrantChanges |
| 94 | decoder := json.NewDecoder(strings.NewReader(string(encoded))) |
| 95 | decoder.DisallowUnknownFields() |
| 96 | if err := decoder.Decode(&changes); err != nil { |
| 97 | return community.AgentGrantChanges{}, errors.New("changes must contain only supported grant fields" ) |
| 98 | } |
| 99 | if changes.Kinds == nil && changes.Rooms == nil && changes.Repos == nil && changes.Sites == nil && changes.Wiki == nil && changes.Jobs == nil && changes.Rate == nil { |
| 100 | return community.AgentGrantChanges{}, errors.New("changes must request at least one permission" ) |
| 101 | } |
| 102 | return changes, nil |
| 103 | } |