clone

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

feat(tinyagent): bridge relay MCP tools into Hermes with per-request signing

Dami committed c1fefe7
tinyagent mcp is a stdio MCP server Hermes runs like any other server
entry. It forwards tools/list and tools/call to the relay's stateless
MCP endpoint with a fresh NIP-98 signature and the relay's header and
_meta contract on every request, offers a curated read set by default
and a write set behind --allow-writes, signs the unsigned events write
tools return and resubmits them, and reports permission refusals and
transport failures as distinct tool errors.
commit
c1fefe784b4facc2e6b2f730fe9bdfb7e86ab721
parent
e98cef486f70dcbe7d2d88b5aff0dc60577b86e1
author
Dami <dami.etoile@gmail.com>
6 files changed, +1388 -1
Adocs/extensions/tinyagent-mcp.md96+++++
Mtinyagent/README.md17+
Atinyagent/client/mcp.go124++++++
Mtinyagent/main.go4+-
Atinyagent/mcp.go513++++++++++++++++++++++++
Atinyagent/mcp_test.go635++++++++++++++++++++++++++++++
A docs/extensions/tinyagent-mcp.md +96 -0
@@ -0,0 +1,96 @@
1+# Tinyagent MCP facade2+3+| Field | Value |4+| --- | --- |5+| Identifier | `tinyagent-mcp` |6+| Status | Experimental |7+| Contract version | 1; no negotiated wire version |8+| Owner | `tinyagent` command and `tinyagent/client` transport; `internal/mcp` and `internal/daemon` serve the relay side. |9+| Wire | Standard MCP stdio (newline-delimited JSON-RPC 2.0) toward the agent host; the relay's stateless Streamable HTTP endpoint with NIP-98 proofs toward the relay. |10+11+The [catalog's audit baseline and transport rules](README.md) apply. The relay endpoint, its headers and its tool table are specified in [MCP](../mcp.md); this document describes the process that bridges an MCP stdio client to that endpoint. It adds no relay-side wire fields.12+13+## Purpose14+15+Hermes Agent and other MCP hosts speak MCP over stdio with an `initialize` handshake and a long-lived session. The relay endpoint has no handshake and no session: every POST is signed and self-describing. `tinyagent mcp` sits between the two. It runs as a child process of the host, answers the handshake locally and forwards tool calls to the relay with a fresh signature per request. The agent key never leaves the process; the host never sees it.16+17+```sh18+tinyagent mcp --relay URL [--key-env TINY_PRIVATE_KEY] [--tools name,name] [--allow-writes]19+```20+21+`--relay` names the relay origin, with a tenant prefix such as `https://relay.example.test/r/acme` when the tenant is served under a path. `--key-env` names the environment variable holding the agent's private key as hex; it is never a command-line argument.22+23+## Host protocol24+25+The facade reads one JSON-RPC 2.0 message per line on stdin and writes one per line on stdout. Nothing else is written to stdout; diagnostics go to stderr.26+27+| Method | Behavior |28+| --- | --- |29+| `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`. |30+| `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. |31+| `ping` | Answers `{}`. |32+| `tools/list` | The relay's tool table restricted to the exposed set, with the relay's descriptions, input schemas and annotations. |33+| `tools/call` | Forwarded to the relay as described below. |34+| Any other request | JSON-RPC error `-32601`. A message that is not one JSON object answers `-32700`. |35+36+Requests are handled concurrently so `ping` is answered while a tool call is in flight; calls to the relay are serialized and each is bounded to two minutes.37+38+## Relay protocol39+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.41+42+## Exposed tools43+44+The facade offers a curated subset of the relay's table. A tool the relay does not list is left out; a tool outside the curated set is never offered, whatever the relay lists. `publish_event`, `read_management` and every management tool stay hidden.45+46+Read tools, offered by default:47+48+`list_repositories`, `read_repository`, `list_issues`, `read_issue`, `list_pull_requests`, `read_pull_request`, `list_files`, `read_file`, `read_attachment`, `list_rooms`, `read_room`, `read_thread`, `list_wiki`, `read_wiki_page`, `list_jobs`, `read_job`, `read_status`.49+50+Write tools, added with `--allow-writes`:51+52+`create_issue`, `create_pull_request`, `comment`, `post_message`, `start_thread`, `reply_in_thread`, `react`, `publish_wiki_page`, `propose_wiki_merge`, `upload_attachment`, `request_decision`, `request_grant`, `request_job`, `accept_job`, `job_progress`, `job_result`, `job_error`, `cancel_job`.53+54+`--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`.55+56+## Sign and resubmit57+58+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.59+60+Rules:61+62+- 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.63+- 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.64+- 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.65+- A result with `isError` is never signed.66+67+## Errors68+69+| Condition | Host sees |70+| --- | --- |71+| 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`. |72+| HTTP 401 or 403 from the relay | A tool error `permission: <relay message>`. |73+| Connection failure, timeout, invalid response or HTTP 5xx | A tool error `transport: <detail>`. `tools/list` reports the same text as JSON-RPC error `-32603`. |74+| Any other JSON-RPC error from the relay | A tool error `relay: <relay message>`. |75+| Tool not in the exposed set | JSON-RPC error `-32602` `Unknown tool`. The relay is not contacted. |76+77+Errors are tool results rather than protocol errors wherever the host would show a tool result to the model.78+79+## Bounds80+81+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.82+83+## Hermes configuration84+85+```yaml86+mcp_servers:87+ tiny:88+ command: tinyagent89+ args: ["mcp", "--relay", "https://relay.example.test", "--allow-writes"]90+ env:91+ TINY_PRIVATE_KEY: "${TINY_PRIVATE_KEY}"92+```93+94+Hermes interpolates `${VAR}` in `mcp_servers` entries from its environment, including `~/.hermes/.env`, and leaves an unset variable as the literal placeholder. A stdio server receives a filtered environment: `PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `TERM`, `SHELL`, `TMPDIR`, `XDG_*` and the entry's `env` map. The key therefore has to be declared in `env`. `command` is resolved against that `PATH`, so the helper must be on it or named by absolute path. Hermes writes the server's stderr to its MCP log.95+96+Sources: [`mcp.go`](../../tinyagent/mcp.go), [`client/mcp.go`](../../tinyagent/client/mcp.go), [`mcp_test.go`](../../tinyagent/mcp_test.go), [`internal/mcp/server.go`](../../internal/mcp/server.go) and [`internal/daemon/mcp_tools.go`](../../internal/daemon/mcp_tools.go).
M tinyagent/README.md +17 -0
@@ -43,4 +43,21 @@ TINY_PRIVATE_KEY=YOUR_HEX_PRIVATE_KEY tinyagent rpc --relay https://relay.exampl
4343 tinyagent call --relay https://relay.example.test identity4444 ```4545 46+## Relay tools in Hermes47+48+`tinyagent mcp` gives Hermes the relay's own MCP tools. It is a stdio MCP server that Hermes starts like any other `mcp_servers` entry, and it forwards each call to `<relay>/mcp` with a fresh NIP-98 signature from the agent key. Add it to `config.yaml`:49+50+```yaml51+mcp_servers:52+ tiny:53+ command: tinyagent54+ args: ["mcp", "--relay", "https://relay.example.test", "--allow-writes"]55+ env:56+ TINY_PRIVATE_KEY: "${TINY_PRIVATE_KEY}"57+```58+59+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.60+61+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).62+4663 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.
A tinyagent/client/mcp.go +124 -0
@@ -0,0 +1,124 @@
1+package client2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "errors"8+ "fmt"9+ "io"10+ "net/http"11+ "strings"12+ "sync/atomic"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"15+)16+17+// MCPError is a JSON-RPC error answered by the relay's MCP endpoint together18+// with the HTTP status it arrived with. Failures to reach the relay at all19+// are returned as ordinary errors instead.20+type MCPError struct {21+ Status int22+ Code int23+ Message string24+ Data any25+}26+27+func (e *MCPError) Error() string {28+ if e.Code != 0 {29+ return fmt.Sprintf("relay HTTP %d, JSON-RPC %d: %s", e.Status, e.Code, e.Message)30+ }31+ return fmt.Sprintf("relay HTTP %d: %s", e.Status, e.Message)32+}33+34+var mcpRequestID atomic.Int6435+36+// MCP posts one JSON-RPC request to the relay's stateless MCP endpoint and37+// returns the raw result. Each call carries a fresh NIP-98 proof bound to38+// the URL and body, the mirrored Mcp-* headers and the reserved _meta39+// fields. The endpoint is /mcp under the client's base URL, so a tenant40+// served under a path prefix is reached at /r/<name>/mcp.41+func (c *Client) MCP(ctx context.Context, method string, params map[string]any, info mcp.Implementation) (json.RawMessage, error) {42+ if method == "" {43+ return nil, errors.New("method is required")44+ }45+ merged := make(map[string]any, len(params)+1)46+ for k, v := range params {47+ merged[k] = v48+ }49+ merged["_meta"] = map[string]any{50+ mcp.MetaProtocolVersion: mcp.Version,51+ mcp.MetaClientCapabilities: map[string]any{},52+ mcp.MetaClientInfo: info,53+ }54+ body, err := json.Marshal(mcp.Request{JSONRPC: "2.0", ID: json.RawMessage(fmt.Sprint(mcpRequestID.Add(1))), Method: method, Params: mustJSON(merged)})55+ if err != nil {56+ return nil, err57+ }58+ u, err := c.requestURL("/mcp")59+ if err != nil {60+ return nil, err61+ }62+ proof, err := c.proof(http.MethodPost, u.String(), body)63+ if err != nil {64+ return nil, err65+ }66+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))67+ if err != nil {68+ return nil, err69+ }70+ req.Header.Set("Authorization", proof)71+ req.Header.Set("Content-Type", "application/json")72+ req.Header.Set("Accept", "application/json")73+ req.Header.Set(mcp.HeaderProtocolVersion, mcp.Version)74+ req.Header.Set(mcp.HeaderMethod, method)75+ if method == "tools/call" {76+ name, _ := params["name"].(string)77+ req.Header.Set(mcp.HeaderName, mcp.EncodeHeaderValue(name))78+ }79+ resp, err := c.httpClient().Do(req)80+ if err != nil {81+ return nil, err82+ }83+ defer resp.Body.Close()84+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))85+ if err != nil {86+ return nil, err87+ }88+ if len(data) > maxDownload {89+ return nil, errors.New("response exceeds 32 MiB")90+ }91+ var response struct {92+ Result json.RawMessage `json:"result"`93+ Error *mcp.Error `json:"error"`94+ }95+ decoded := json.Unmarshal(data, &response) == nil96+ if resp.StatusCode/100 != 2 {97+ if decoded && response.Error != nil {98+ return nil, &MCPError{Status: resp.StatusCode, Code: response.Error.Code, Message: response.Error.Message, Data: response.Error.Data}99+ }100+ message := strings.TrimSpace(string(data))101+ if message == "" {102+ message = resp.Status103+ }104+ return nil, &MCPError{Status: resp.StatusCode, Message: message}105+ }106+ if !decoded {107+ return nil, errors.New("relay returned an invalid JSON-RPC response")108+ }109+ if response.Error != nil {110+ return nil, &MCPError{Status: resp.StatusCode, Code: response.Error.Code, Message: response.Error.Message, Data: response.Error.Data}111+ }112+ if len(response.Result) == 0 {113+ return nil, errors.New("relay returned a JSON-RPC response without a result")114+ }115+ return response.Result, nil116+}117+118+func mustJSON(value any) json.RawMessage {119+ raw, err := json.Marshal(value)120+ if err != nil {121+ return json.RawMessage("{}")122+ }123+ return raw124+}
M tinyagent/main.go +3 -1
A tinyagent/mcp.go +513 -0
@@ -0,0 +1,513 @@
1+package main2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "encoding/json"8+ "errors"9+ "flag"10+ "fmt"11+ "io"12+ "os"13+ "runtime/debug"14+ "strings"15+ "sync"16+ "time"17+ "unicode/utf8"18+19+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"20+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"21+ "github.com/FelineStateMachine/tinyrelay/tinyagent/client"22+)23+24+// The facade speaks the standard MCP stdio protocol to Hermes and forwards25+// 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.28+29+// mcpReadTools are exposed by default.30+var mcpReadTools = []string{31+ "list_repositories", "read_repository",32+ "list_issues", "read_issue",33+ "list_pull_requests", "read_pull_request",34+ "list_files", "read_file", "read_attachment",35+ "list_rooms", "read_room", "read_thread",36+ "list_wiki", "read_wiki_page",37+ "list_jobs", "read_job",38+ "read_status",39+}40+41+// mcpWriteTools are added with --allow-writes.42+var mcpWriteTools = []string{43+ "create_issue", "create_pull_request", "comment",44+ "post_message", "start_thread", "reply_in_thread", "react",45+ "publish_wiki_page", "propose_wiki_merge",46+ "upload_attachment",47+ "request_decision", "request_grant",48+ "request_job", "accept_job", "job_progress", "job_result", "job_error", "cancel_job",49+}50+51+// mcpHandshakeVersions are the revisions the official SDK's stdio client may52+// send in initialize. Any other request is answered with mcpFallbackVersion.53+var mcpHandshakeVersions = []string{"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}54+55+const (56+ mcpFallbackVersion = "2025-06-18"57+ mcpTextLimit = 64 << 1058+ mcpCallTimeout = 2 * time.Minute59+ mcpPermission = "permission: "60+ mcpTransport = "transport: "61+)62+63+// mcpAuthKinds are never signed on the relay's behalf: a template of one of64+// these kinds would be an authorization, not a publication.65+var mcpAuthKinds = map[int]bool{22242: true, 24242: true, 27235: true}66+67+type mcpFacade struct {68+ client *client.Client69+ secret string70+ allowed map[string]bool71+ order []string72+ info mcp.Implementation73+ stderr io.Writer74+ relayMu sync.Mutex75+ tableMu sync.Mutex76+ table []map[string]any77+ outMu sync.Mutex78+}79+80+// mcpToolResult is the subset of a tools/call result the facade relays.81+type mcpToolResult struct {82+ Content []map[string]any `json:"content"`83+ StructuredContent any `json:"structuredContent,omitempty"`84+ IsError bool `json:"isError"`85+}86+87+func mcpCommand(args []string, in io.Reader, out, stderr io.Writer) error {88+ fs := flag.NewFlagSet("mcp", flag.ContinueOnError)89+ fs.SetOutput(io.Discard)90+ relay := fs.String("relay", "", "relay URL")91+ keyEnv := fs.String("key-env", "TINY_PRIVATE_KEY", "private key environment variable")92+ tools := fs.String("tools", "", "comma-separated tool names to expose")93+ writes := fs.Bool("allow-writes", false, "expose write tools")94+ if err := fs.Parse(args); err != nil {95+ return err96+ }97+ if *relay == "" {98+ return errors.New("usage: tinyagent mcp --relay URL [--key-env TINY_PRIVATE_KEY] [--tools name,name] [--allow-writes]")99+ }100+ secret := os.Getenv(*keyEnv)101+ if secret == "" {102+ return fmt.Errorf("%s is not set", *keyEnv)103+ }104+ c, err := client.New(*relay, secret)105+ if err != nil {106+ return err107+ }108+ order, err := mcpAllowlist(*tools, *writes)109+ if err != nil {110+ return err111+ }112+ f := newMCPFacade(c, secret, order, stderr)113+ fmt.Fprintf(stderr, "tinyagent mcp: relay %s, %d tools\n", c.BaseURL, len(order))114+ return f.serve(in, out)115+}116+117+func newMCPFacade(c *client.Client, secret string, order []string, stderr io.Writer) *mcpFacade {118+ allowed := make(map[string]bool, len(order))119+ for _, name := range order {120+ allowed[name] = true121+ }122+ return &mcpFacade{client: c, secret: secret, allowed: allowed, order: order, info: mcp.Implementation{Name: "tinyagent", Version: tinyagentVersion()}, stderr: stderr}123+}124+125+// mcpAllowlist resolves the exposed tool names. Without --tools the read126+// tools are exposed, plus the write tools with --allow-writes. A --tools127+// list may name any curated tool; write tools in it still need128+// --allow-writes.129+func mcpAllowlist(spec string, writes bool) ([]string, error) {130+ reads := map[string]bool{}131+ for _, name := range mcpReadTools {132+ reads[name] = true133+ }134+ writeSet := map[string]bool{}135+ for _, name := range mcpWriteTools {136+ writeSet[name] = true137+ }138+ if strings.TrimSpace(spec) == "" {139+ order := append([]string(nil), mcpReadTools...)140+ if writes {141+ order = append(order, mcpWriteTools...)142+ }143+ return order, nil144+ }145+ var order []string146+ seen := map[string]bool{}147+ for _, raw := range strings.Split(spec, ",") {148+ name := strings.TrimSpace(raw)149+ if name == "" || seen[name] {150+ continue151+ }152+ switch {153+ case reads[name]:154+ case writeSet[name]:155+ if !writes {156+ return nil, fmt.Errorf("tool %s publishes events; add --allow-writes to expose it", name)157+ }158+ default:159+ return nil, fmt.Errorf("unknown tool %q in --tools", name)160+ }161+ seen[name] = true162+ order = append(order, name)163+ }164+ if len(order) == 0 {165+ return nil, errors.New("--tools names no tool")166+ }167+ return order, nil168+}169+170+func tinyagentVersion() string {171+ if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {172+ return info.Main.Version173+ }174+ return "dev"175+}176+177+// serve reads newline-delimited JSON-RPC messages from in and answers on178+// out. Requests run concurrently so pings are answered while a relay call179+// is in flight; relay calls themselves are serialized. Notifications,180+// including cancellations, are ignored.181+func (f *mcpFacade) serve(in io.Reader, out io.Writer) error {182+ scan := bufio.NewScanner(in)183+ scan.Buffer(make([]byte, 4096), 48<<20)184+ ctx, cancel := context.WithCancel(context.Background())185+ defer cancel()186+ var wg sync.WaitGroup187+ for scan.Scan() {188+ line := bytes.TrimSpace(scan.Bytes())189+ if len(line) == 0 {190+ continue191+ }192+ var req mcp.Request193+ if err := json.Unmarshal(line, &req); err != nil || !bytes.HasPrefix(line, []byte("{")) {194+ f.write(out, mcp.Response{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &mcp.Error{Code: mcp.CodeParse, Message: "Parse error: the message is not one JSON-RPC object"}})195+ continue196+ }197+ if req.Notification() || string(bytes.TrimSpace(req.ID)) == "null" {198+ continue199+ }200+ wg.Add(1)201+ go func(req mcp.Request) {202+ defer wg.Done()203+ f.write(out, f.handle(ctx, req))204+ }(req)205+ }206+ wg.Wait()207+ return scan.Err()208+}209+210+func (f *mcpFacade) write(out io.Writer, response mcp.Response) {211+ f.outMu.Lock()212+ defer f.outMu.Unlock()213+ raw, err := json.Marshal(response)214+ if err != nil {215+ fmt.Fprintf(f.stderr, "tinyagent mcp: encode response: %v\n", err)216+ return217+ }218+ _, _ = out.Write(append(raw, '\n'))219+}220+221+func (f *mcpFacade) handle(ctx context.Context, req mcp.Request) mcp.Response {222+ response := mcp.Response{JSONRPC: "2.0", ID: req.ID}223+ if req.JSONRPC != "2.0" || req.Method == "" {224+ response.Error = &mcp.Error{Code: mcp.CodeInvalidRequest, Message: "jsonrpc must be \"2.0\" and method is required"}225+ return response226+ }227+ var result any228+ var rpcErr *mcp.Error229+ switch req.Method {230+ case "initialize":231+ result = f.initialize(req.Params)232+ case "ping":233+ result = struct{}{}234+ case "tools/list":235+ result, rpcErr = f.list(ctx)236+ case "tools/call":237+ result, rpcErr = f.call(ctx, req.Params)238+ default:239+ rpcErr = &mcp.Error{Code: mcp.CodeMethodNotFound, Message: "Method not found: " + req.Method}240+ }241+ if rpcErr != nil {242+ response.Error = rpcErr243+ return response244+ }245+ response.Result = result246+ return response247+}248+249+func (f *mcpFacade) initialize(params json.RawMessage) map[string]any {250+ var p struct {251+ ProtocolVersion string `json:"protocolVersion"`252+ }253+ _ = json.Unmarshal(params, &p)254+ version := mcpFallbackVersion255+ for _, known := range mcpHandshakeVersions {256+ if p.ProtocolVersion == known {257+ version = known258+ }259+ }260+ return map[string]any{261+ "protocolVersion": version,262+ "capabilities": map[string]any{"tools": map[string]any{}},263+ "serverInfo": f.info,264+ }265+}266+267+func (f *mcpFacade) list(ctx context.Context) (map[string]any, *mcp.Error) {268+ tools, err := f.tools(ctx)269+ if err != nil {270+ return nil, &mcp.Error{Code: mcp.CodeInternal, Message: f.describe(err)}271+ }272+ return map[string]any{"tools": tools}, nil273+}274+275+// tools returns the relay's tool table restricted to the allowlist, in276+// allowlist order, with the relay's descriptions and schemas. The table is277+// fetched once per process and again after an unknown-tool error.278+func (f *mcpFacade) tools(ctx context.Context) ([]map[string]any, error) {279+ f.tableMu.Lock()280+ defer f.tableMu.Unlock()281+ if f.table != nil {282+ return f.table, nil283+ }284+ raw, err := f.relay(ctx, "tools/list", map[string]any{})285+ if err != nil {286+ return nil, err287+ }288+ var listed struct {289+ Tools []map[string]any `json:"tools"`290+ }291+ if err := json.Unmarshal(raw, &listed); err != nil {292+ return nil, errors.New("relay returned an invalid tool table")293+ }294+ byName := map[string]map[string]any{}295+ for _, tool := range listed.Tools {296+ if name, _ := tool["name"].(string); name != "" {297+ byName[name] = tool298+ }299+ }300+ table := []map[string]any{}301+ for _, name := range f.order {302+ tool, ok := byName[name]303+ if !ok {304+ continue305+ }306+ entry := map[string]any{"name": name}307+ for _, key := range []string{"title", "description", "inputSchema", "outputSchema", "annotations"} {308+ if value, ok := tool[key]; ok && value != nil {309+ entry[key] = value310+ }311+ }312+ if _, ok := entry["inputSchema"]; !ok {313+ entry["inputSchema"] = map[string]any{"type": "object"}314+ }315+ table = append(table, entry)316+ }317+ f.table = table318+ return table, nil319+}320+321+func (f *mcpFacade) invalidate() {322+ f.tableMu.Lock()323+ f.table = nil324+ f.tableMu.Unlock()325+}326+327+func (f *mcpFacade) call(ctx context.Context, params json.RawMessage) (any, *mcp.Error) {328+ var p struct {329+ Name string `json:"name"`330+ Arguments map[string]any `json:"arguments"`331+ }332+ if len(params) > 0 {333+ if err := json.Unmarshal(params, &p); err != nil {334+ return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "params must be an object with name and arguments"}335+ }336+ }337+ if p.Name == "" {338+ return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "params.name is required"}339+ }340+ if !f.allowed[p.Name] {341+ return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "Unknown tool: " + p.Name}342+ }343+ if p.Arguments == nil {344+ p.Arguments = map[string]any{}345+ }346+ result := f.forward(ctx, p.Name, p.Arguments)347+ if template, ok := mcpUnsignedTemplate(result); ok {348+ result = f.signAndResubmit(ctx, p.Name, p.Arguments, template)349+ }350+ return mcpBound(mcpPermissionError(result)), nil351+}352+353+// forward runs one tools/call on the relay and turns every failure into a354+// tool result the model can read.355+func (f *mcpFacade) forward(ctx context.Context, name string, arguments map[string]any) mcpToolResult {356+ raw, err := f.relay(ctx, "tools/call", map[string]any{"name": name, "arguments": arguments})357+ if err != nil {358+ var relayErr *client.MCPError359+ if errors.As(err, &relayErr) && relayErr.Code == mcp.CodeInvalidParams && strings.HasPrefix(relayErr.Message, "Unknown tool") {360+ f.invalidate()361+ }362+ return mcpFailure(f.describe(err))363+ }364+ var result mcpToolResult365+ if err := json.Unmarshal(raw, &result); err != nil {366+ return mcpFailure(mcpTransport + "the relay returned an invalid tool result")367+ }368+ if result.Content == nil {369+ result.Content = []map[string]any{}370+ }371+ return result372+}373+374+// describe classifies a relay failure: HTTP 401 and 403 are permission375+// errors, unreachable relays and 5xx answers are transport errors, and any376+// other JSON-RPC error is reported with the relay's message.377+func (f *mcpFacade) describe(err error) string {378+ var relayErr *client.MCPError379+ if !errors.As(err, &relayErr) {380+ return mcpTransport + err.Error()381+ }382+ switch {383+ case relayErr.Status == 401 || relayErr.Status == 403:384+ return mcpPermission + relayErr.Message385+ case relayErr.Status >= 500:386+ return mcpTransport + relayErr.Error()387+ default:388+ return "relay: " + relayErr.Message389+ }390+}391+392+// relay serializes calls to the relay and bounds each one.393+func (f *mcpFacade) relay(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {394+ f.relayMu.Lock()395+ defer f.relayMu.Unlock()396+ ctx, cancel := context.WithTimeout(ctx, mcpCallTimeout)397+ defer cancel()398+ return f.client.MCP(ctx, method, params, f.info)399+}400+401+// mcpUnsignedTemplate recognizes a write tool's first answer: a successful402+// result whose structuredContent.unsigned carries kind, tags and content.403+func mcpUnsignedTemplate(result mcpToolResult) (nostr.Event, bool) {404+ if result.IsError {405+ return nostr.Event{}, false406+ }407+ structured, ok := result.StructuredContent.(map[string]any)408+ if !ok {409+ return nostr.Event{}, false410+ }411+ unsigned, ok := structured["unsigned"].(map[string]any)412+ if !ok {413+ return nostr.Event{}, false414+ }415+ kind, ok := unsigned["kind"].(float64)416+ if !ok || kind < 0 || kind != float64(int(kind)) {417+ return nostr.Event{}, false418+ }419+ content, ok := unsigned["content"].(string)420+ if !ok {421+ return nostr.Event{}, false422+ }423+ rawTags, ok := unsigned["tags"].([]any)424+ if !ok {425+ return nostr.Event{}, false426+ }427+ tags := make([][]string, 0, len(rawTags))428+ for _, rawTag := range rawTags {429+ values, ok := rawTag.([]any)430+ if !ok {431+ return nostr.Event{}, false432+ }433+ tag := make([]string, 0, len(values))434+ for _, value := range values {435+ text, ok := value.(string)436+ if !ok {437+ return nostr.Event{}, false438+ }439+ tag = append(tag, text)440+ }441+ tags = append(tags, tag)442+ }443+ createdAt, _ := unsigned["created_at"].(float64)444+ return nostr.Event{Kind: int(kind), CreatedAt: int64(createdAt), Tags: tags, Content: content}, true445+}446+447+// signAndResubmit signs the relay's template as returned, with no change to448+// its kind, tags or content, and calls the same tool again with the signed449+// event. The relay's publish result is what the model sees.450+func (f *mcpFacade) signAndResubmit(ctx context.Context, name string, arguments map[string]any, template nostr.Event) mcpToolResult {451+ if mcpAuthKinds[template.Kind] {452+ return mcpFailure(fmt.Sprintf("refused to sign a kind %d authorization event returned by %s", template.Kind, name))453+ }454+ if template.CreatedAt == 0 {455+ template.CreatedAt = time.Now().Unix()456+ }457+ if err := nostr.Sign(&template, f.secret); err != nil {458+ return mcpFailure("sign: " + err.Error())459+ }460+ resubmit := make(map[string]any, len(arguments)+1)461+ for k, v := range arguments {462+ resubmit[k] = v463+ }464+ resubmit["event"] = template465+ return f.forward(ctx, name, resubmit)466+}467+468+// mcpPermissionError marks relay refusals so the agent can ask for a grant.469+func mcpPermissionError(result mcpToolResult) mcpToolResult {470+ if !result.IsError {471+ return result472+ }473+ if structured, ok := result.StructuredContent.(map[string]any); ok {474+ if message, _ := structured["message"].(string); mcpRestricted(message) {475+ return mcpFailureWith(mcpPermission+message, result.StructuredContent)476+ }477+ }478+ for i, block := range result.Content {479+ text, _ := block["text"].(string)480+ if block["type"] == "text" && mcpRestricted(text) {481+ result.Content[i]["text"] = mcpPermission + text482+ }483+ }484+ return result485+}486+487+func mcpRestricted(message string) bool {488+ return strings.HasPrefix(message, "restricted:") || strings.HasPrefix(message, "auth-required:")489+}490+491+// mcpBound caps each text block at mcpTextLimit bytes and says so.492+func mcpBound(result mcpToolResult) mcpToolResult {493+ for i, block := range result.Content {494+ text, ok := block["text"].(string)495+ if !ok || len(text) <= mcpTextLimit {496+ continue497+ }498+ cut := mcpTextLimit499+ for cut > 0 && !utf8.RuneStart(text[cut]) {500+ cut--501+ }502+ result.Content[i]["text"] = text[:cut] + fmt.Sprintf("\n[truncated: %d of %d bytes shown]", cut, len(text))503+ }504+ return result505+}506+507+func mcpFailure(message string) mcpToolResult {508+ return mcpFailureWith(message, nil)509+}510+511+func mcpFailureWith(message string, structured any) mcpToolResult {512+ return mcpToolResult{Content: []map[string]any{{"type": "text", "text": message}}, StructuredContent: structured, IsError: true}513+}
A tinyagent/mcp_test.go +635 -0
@@ -0,0 +1,635 @@
1+package main2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "crypto/sha256"8+ "encoding/base64"9+ "encoding/hex"10+ "encoding/json"11+ "errors"12+ "fmt"13+ "io"14+ "net/http"15+ "net/http/httptest"16+ "strings"17+ "sync"18+ "testing"19+ "time"20+21+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"22+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"23+)24+25+// fakeRelay is a stateless MCP endpoint that checks every request the way26+// the relay does and serves a small tool table.27+type fakeRelay struct {28+ t *testing.T29+ prefix string30+ pubkey string31+ mu sync.Mutex32+ calls []string33+ events []nostr.Event34+ server *httptest.Server35+}36+37+var fakeTools = []map[string]any{38+ {"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}},39+ {"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"}}},40+ {"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"}}}},41+ {"name": "request_job", "description": "Ask for a long task.", "inputSchema": map[string]any{"type": "object"}},42+ {"name": "publish_event", "description": "Publish any event.", "inputSchema": map[string]any{"type": "object"}},43+ {"name": "set_policy", "description": "Change policy.", "inputSchema": map[string]any{"type": "object"}},44+}45+46+func newFakeRelay(t *testing.T, prefix, pubkey string) *fakeRelay {47+ f := &fakeRelay{t: t, prefix: prefix, pubkey: pubkey}48+ f.server = httptest.NewServer(f)49+ t.Cleanup(f.server.Close)50+ return f51+}52+53+func (f *fakeRelay) base() string { return f.server.URL + f.prefix }54+55+func (f *fakeRelay) callList() []string {56+ f.mu.Lock()57+ defer f.mu.Unlock()58+ return append([]string(nil), f.calls...)59+}60+61+func (f *fakeRelay) eventList() []nostr.Event {62+ f.mu.Lock()63+ defer f.mu.Unlock()64+ return append([]nostr.Event(nil), f.events...)65+}66+67+func (f *fakeRelay) fail(w http.ResponseWriter, status, code int, message string) {68+ f.t.Errorf("fake relay refused a request: %d %s", status, message)69+ w.Header().Set("Content-Type", "application/json")70+ w.WriteHeader(status)71+ _ = json.NewEncoder(w).Encode(mcp.Response{JSONRPC: "2.0", ID: json.RawMessage("1"), Error: &mcp.Error{Code: code, Message: message}})72+}73+74+func (f *fakeRelay) ServeHTTP(w http.ResponseWriter, r *http.Request) {75+ if r.Method != http.MethodPost || r.URL.Path != f.prefix+"/mcp" {76+ f.fail(w, http.StatusNotFound, mcp.CodeInvalidRequest, "wrong method or path: "+r.Method+" "+r.URL.Path)77+ return78+ }79+ body, _ := io.ReadAll(r.Body)80+ if err := f.checkProof(r, body); err != nil {81+ f.fail(w, http.StatusUnauthorized, mcp.CodeInvalidRequest, err.Error())82+ return83+ }84+ if r.Header.Get("Content-Type") != "application/json" || !strings.Contains(r.Header.Get("Accept"), "application/json") {85+ f.fail(w, http.StatusBadRequest, mcp.CodeInvalidRequest, "content negotiation headers")86+ return87+ }88+ var request mcp.Request89+ if err := json.Unmarshal(body, &request); err != nil || request.JSONRPC != "2.0" {90+ f.fail(w, http.StatusBadRequest, mcp.CodeParse, "body")91+ return92+ }93+ var params struct {94+ Meta map[string]json.RawMessage `json:"_meta"`95+ Name string `json:"name"`96+ Arguments map[string]any `json:"arguments"`97+ }98+ _ = json.Unmarshal(request.Params, &params)99+ if r.Header.Get(mcp.HeaderProtocolVersion) != mcp.Version || string(params.Meta[mcp.MetaProtocolVersion]) != `"`+mcp.Version+`"` {100+ f.fail(w, http.StatusBadRequest, mcp.CodeHeaderMismatch, "protocol version")101+ return102+ }103+ if string(params.Meta[mcp.MetaClientCapabilities]) != "{}" || !bytes.Contains(params.Meta[mcp.MetaClientInfo], []byte(`"tinyagent"`)) {104+ f.fail(w, http.StatusBadRequest, mcp.CodeInvalidParams, "_meta")105+ return106+ }107+ if r.Header.Get(mcp.HeaderMethod) != request.Method {108+ f.fail(w, http.StatusBadRequest, mcp.CodeHeaderMismatch, "Mcp-Method")109+ return110+ }111+ if request.Method == "tools/call" {112+ if name, err := mcp.DecodeHeaderValue(r.Header.Get(mcp.HeaderName)); err != nil || name != params.Name {113+ f.fail(w, http.StatusBadRequest, mcp.CodeHeaderMismatch, "Mcp-Name")114+ return115+ }116+ }117+ answer := func(status int, result any, rpcErr *mcp.Error) {118+ w.Header().Set("Content-Type", "application/json")119+ w.WriteHeader(status)120+ _ = json.NewEncoder(w).Encode(mcp.Response{JSONRPC: "2.0", ID: request.ID, Result: result, Error: rpcErr})121+ }122+ switch request.Method {123+ case "tools/list":124+ answer(http.StatusOK, map[string]any{"tools": fakeTools, "ttlMs": 300000, "cacheScope": "private"}, nil)125+ case "tools/call":126+ f.mu.Lock()127+ f.calls = append(f.calls, params.Name)128+ f.mu.Unlock()129+ f.callTool(answer, params.Name, params.Arguments)130+ default:131+ answer(http.StatusNotFound, nil, &mcp.Error{Code: mcp.CodeMethodNotFound, Message: "method " + request.Method + " is not supported"})132+ }133+}134+135+func (f *fakeRelay) checkProof(r *http.Request, body []byte) error {136+ header := r.Header.Get("Authorization")137+ if !strings.HasPrefix(header, "Nostr ") {138+ return fmt.Errorf("missing Nostr authorization")139+ }140+ raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, "Nostr "))141+ if err != nil {142+ return err143+ }144+ e, err := nostr.Parse(raw)145+ if err != nil {146+ return err147+ }148+ if err := nostr.Validate(e); err != nil {149+ return err150+ }151+ sum := sha256.Sum256(body)152+ switch {153+ case e.Kind != 27235:154+ return fmt.Errorf("kind %d", e.Kind)155+ case e.PubKey != f.pubkey:156+ return fmt.Errorf("signer %s", e.PubKey)157+ case nostr.Tag(e, "u") != f.server.URL+r.URL.RequestURI():158+ return fmt.Errorf("u tag %s", nostr.Tag(e, "u"))159+ case nostr.Tag(e, "method") != http.MethodPost:160+ return fmt.Errorf("method tag %s", nostr.Tag(e, "method"))161+ case nostr.Tag(e, "payload") != hex.EncodeToString(sum[:]):162+ return fmt.Errorf("payload tag")163+ case time.Now().Unix()-e.CreatedAt > 60:164+ return fmt.Errorf("stale token")165+ }166+ return nil167+}168+169+func (f *fakeRelay) callTool(answer func(int, any, *mcp.Error), name string, arguments map[string]any) {170+ ok := func(structured any) {171+ text, _ := json.Marshal(structured)172+ answer(http.StatusOK, map[string]any{"resultType": "complete", "content": []any{map[string]any{"type": "text", "text": string(text)}}, "structuredContent": structured, "isError": false}, nil)173+ }174+ failure := func(message string, structured any) {175+ answer(http.StatusOK, map[string]any{"resultType": "complete", "content": []any{map[string]any{"type": "text", "text": message}}, "structuredContent": structured, "isError": true}, nil)176+ }177+ switch name {178+ case "list_rooms":179+ if arguments["q"] == "secret" {180+ failure("restricted: members only", nil)181+ return182+ }183+ ok(map[string]any{"rooms": []any{map[string]any{"id": "lobby", "url": f.base() + "/chat/lobby", "address": "/chat/lobby"}}})184+ case "read_room":185+ switch arguments["id"] {186+ case "forbidden":187+ answer(http.StatusForbidden, nil, &mcp.Error{Code: mcp.CodeInvalidRequest, Message: "origin is not allowed"})188+ case "unauthorized":189+ answer(http.StatusUnauthorized, nil, &mcp.Error{Code: mcp.CodeInvalidRequest, Message: "auth-required: sign this request"})190+ case "boom":191+ answer(http.StatusInternalServerError, nil, &mcp.Error{Code: mcp.CodeInternal, Message: "store unavailable"})192+ case "huge":193+ ok(map[string]any{"text": strings.Repeat("x", 100<<10)})194+ case "rejected":195+ failure("The relay rejected the event: restricted: room is read-only", map[string]any{"accepted": false, "message": "restricted: room is read-only"})196+ default:197+ ok(map[string]any{"id": arguments["id"], "messages": []any{}})198+ }199+ 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": ""})214+ return215+ }216+ room, _ := arguments["room"].(string)217+ content, _ := arguments["content"].(string)218+ if room == "" || content == "" {219+ failure("room and content are required", nil)220+ return221+ }222+ if room == "auth" {223+ ok(map[string]any{"unsigned": map[string]any{"kind": 27235, "created_at": time.Now().Unix(), "tags": []any{[]any{"u", "https://elsewhere.test/"}}, "content": ""}, "next": "sign"})224+ return225+ }226+ ok(map[string]any{"unsigned": map[string]any{"kind": 9, "created_at": time.Now().Unix(), "tags": []any{[]any{"h", room}, []any{"p", f.pubkey}}, "content": content}, "next": "Sign this event and call again with event."})227+ default:228+ answer(http.StatusBadRequest, nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "Unknown tool: " + name})229+ }230+}231+232+// facadeConn drives mcpCommand over in-memory pipes as a scripted client.233+type facadeConn struct {234+ t *testing.T235+ in *io.PipeWriter236+ out *bufio.Reader237+ stderr *bytes.Buffer238+ done chan error239+ next int240+}241+242+func startFacade(t *testing.T, args ...string) *facadeConn {243+ t.Helper()244+ inReader, inWriter := io.Pipe()245+ outReader, outWriter := io.Pipe()246+ c := &facadeConn{t: t, in: inWriter, out: bufio.NewReaderSize(outReader, 1<<20), stderr: &bytes.Buffer{}, done: make(chan error, 1)}247+ go func() {248+ err := mcpCommand(args, inReader, outWriter, c.stderr)249+ outWriter.Close()250+ c.done <- err251+ }()252+ t.Cleanup(func() {253+ inWriter.Close()254+ select {255+ case <-c.done:256+ case <-time.After(5 * time.Second):257+ }258+ })259+ return c260+}261+262+func (c *facadeConn) send(line string) {263+ c.t.Helper()264+ if _, err := io.WriteString(c.in, line+"\n"); err != nil {265+ c.t.Fatal(err)266+ }267+}268+269+func (c *facadeConn) read() mcp.Response {270+ c.t.Helper()271+ line, err := c.out.ReadBytes('\n')272+ if err != nil {273+ c.t.Fatalf("read response: %v (stderr: %s)", err, c.stderr.String())274+ }275+ var response mcp.Response276+ if err := json.Unmarshal(line, &response); err != nil {277+ c.t.Fatalf("invalid response %s: %v", line, err)278+ }279+ return response280+}281+282+func (c *facadeConn) call(method string, params any) mcp.Response {283+ c.t.Helper()284+ c.next++285+ raw, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": c.next, "method": method, "params": params})286+ c.send(string(raw))287+ response := c.read()288+ if string(response.ID) != fmt.Sprint(c.next) {289+ c.t.Fatalf("response id %s for request %d", response.ID, c.next)290+ }291+ return response292+}293+294+func (c *facadeConn) initialize(version string) map[string]any {295+ c.t.Helper()296+ response := c.call("initialize", map[string]any{"protocolVersion": version, "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "mcp", "version": "1.26.0"}})297+ if response.Error != nil {298+ c.t.Fatalf("initialize: %v", response.Error)299+ }300+ c.send(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)301+ result, _ := response.Result.(map[string]any)302+ return result303+}304+305+func (c *facadeConn) listNames() []string {306+ c.t.Helper()307+ response := c.call("tools/list", map[string]any{})308+ if response.Error != nil {309+ c.t.Fatalf("tools/list: %v", response.Error)310+ }311+ tools, _ := response.Result.(map[string]any)["tools"].([]any)312+ names := make([]string, 0, len(tools))313+ for _, tool := range tools {314+ name, _ := tool.(map[string]any)["name"].(string)315+ names = append(names, name)316+ }317+ return names318+}319+320+func (c *facadeConn) tool(name string, arguments map[string]any) (mcpToolResult, mcp.Response) {321+ c.t.Helper()322+ response := c.call("tools/call", map[string]any{"name": name, "arguments": arguments})323+ var result mcpToolResult324+ if response.Error == nil {325+ raw, _ := json.Marshal(response.Result)326+ if err := json.Unmarshal(raw, &result); err != nil {327+ c.t.Fatalf("tool result %s: %v", raw, err)328+ }329+ }330+ return result, response331+}332+333+func firstText(result mcpToolResult) string {334+ if len(result.Content) == 0 {335+ return ""336+ }337+ text, _ := result.Content[0]["text"].(string)338+ return text339+}340+341+func testKey(t *testing.T) (string, string) {342+ t.Helper()343+ secret, err := nostr.GenerateKey()344+ if err != nil {345+ t.Fatal(err)346+ }347+ pub, err := nostr.PublicKey(secret)348+ if err != nil {349+ t.Fatal(err)350+ }351+ t.Setenv("TINY_MCP_TEST_KEY", secret)352+ return secret, pub353+}354+355+func TestMCPInitializeAndFilteredList(t *testing.T) {356+ _, pub := testKey(t)357+ relay := newFakeRelay(t, "", pub)358+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")359+ result := c.initialize("2025-11-25")360+ if result["protocolVersion"] != "2025-11-25" {361+ t.Fatalf("protocol version %v", result["protocolVersion"])362+ }363+ if capabilities, _ := result["capabilities"].(map[string]any); capabilities["tools"] == nil {364+ t.Fatalf("capabilities %v", result["capabilities"])365+ }366+ if info, _ := result["serverInfo"].(map[string]any); info["name"] != "tinyagent" {367+ t.Fatalf("serverInfo %v", result["serverInfo"])368+ }369+ if ping := c.call("ping", nil); ping.Error != nil || ping.Result == nil {370+ t.Fatalf("ping: %+v", ping)371+ }372+ names := c.listNames()373+ if strings.Join(names, ",") != "list_rooms,read_room" {374+ t.Fatalf("read-only table: %v", names)375+ }376+ response := c.call("tools/list", map[string]any{})377+ tool := response.Result.(map[string]any)["tools"].([]any)[0].(map[string]any)378+ if tool["description"] != "List rooms." || tool["inputSchema"] == nil || tool["annotations"] == nil {379+ t.Fatalf("relay schema not passed through: %v", tool)380+ }381+ if unknown := c.call("resources/list", nil); unknown.Error == nil || unknown.Error.Code != mcp.CodeMethodNotFound {382+ t.Fatalf("unknown method: %+v", unknown)383+ }384+ c.send(`{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}`)385+ c.send(`not json`)386+ if parse := c.read(); parse.Error == nil || parse.Error.Code != mcp.CodeParse {387+ t.Fatalf("parse error: %+v", parse)388+ }389+ if ping := c.call("ping", nil); ping.Error != nil {390+ t.Fatalf("ping after notifications: %+v", ping)391+ }392+ if strings.Contains(c.stderr.String(), "\"jsonrpc\"") {393+ t.Fatalf("protocol output on stderr: %s", c.stderr.String())394+ }395+}396+397+func TestMCPInitializeFallsBackToKnownVersion(t *testing.T) {398+ _, pub := testKey(t)399+ relay := newFakeRelay(t, "", pub)400+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")401+ if result := c.initialize("2030-01-01"); result["protocolVersion"] != mcpFallbackVersion {402+ t.Fatalf("fallback version %v", result["protocolVersion"])403+ }404+}405+406+func TestMCPAllowWritesAndTenantPrefix(t *testing.T) {407+ _, pub := testKey(t)408+ relay := newFakeRelay(t, "/r/acme", pub)409+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")410+ c.initialize("2025-06-18")411+ names := c.listNames()412+ if strings.Join(names, ",") != "list_rooms,read_room,post_message,request_job" {413+ t.Fatalf("write table: %v", names)414+ }415+ for _, name := range []string{"publish_event", "set_policy"} {416+ if _, response := c.tool(name, nil); response.Error == nil || response.Error.Code != mcp.CodeInvalidParams {417+ t.Fatalf("%s must stay hidden: %+v", name, response)418+ }419+ }420+ if len(relay.callList()) != 0 {421+ t.Fatalf("hidden tools reached the relay: %v", relay.callList())422+ }423+}424+425+func TestMCPCallSignsAndResubmits(t *testing.T) {426+ _, pub := testKey(t)427+ relay := newFakeRelay(t, "", pub)428+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")429+ c.initialize("2025-06-18")430+ result, response := c.tool("post_message", map[string]any{"room": "lobby", "content": "hello"})431+ if response.Error != nil || result.IsError {432+ t.Fatalf("post_message: %+v %+v", response.Error, result)433+ }434+ structured, _ := result.StructuredContent.(map[string]any)435+ if structured["accepted"] != true || structured["event_id"] == "" {436+ t.Fatalf("publish receipt: %v", structured)437+ }438+ if strings.Join(relay.callList(), ",") != "post_message,post_message" {439+ t.Fatalf("relay calls: %v", relay.callList())440+ }441+ if len(relay.eventList()) != 1 {442+ t.Fatalf("published events: %d", len(relay.eventList()))443+ }444+ e := relay.eventList()[0]445+ tags, _ := json.Marshal(e.Tags)446+ if e.Kind != 9 || e.PubKey != pub || e.Content != "hello" || string(tags) != `[["h","lobby"],["p","`+pub+`"]]` || e.ID != structured["event_id"] {447+ t.Fatalf("signed event differs from the template: %+v", e)448+ }449+ // A read result passes through with its URLs and path addresses intact.450+ result, _ = c.tool("list_rooms", map[string]any{})451+ rooms, _ := result.StructuredContent.(map[string]any)["rooms"].([]any)452+ room, _ := rooms[0].(map[string]any)453+ if result.IsError || room["url"] != relay.base()+"/chat/lobby" || room["address"] != "/chat/lobby" {454+ t.Fatalf("list_rooms: %+v", result)455+ }456+ // A template of an authorization kind is never signed.457+ result, _ = c.tool("post_message", map[string]any{"room": "auth", "content": "x"})458+ if !result.IsError || !strings.Contains(firstText(result), "refused to sign a kind 27235") || len(relay.eventList()) != 1 {459+ t.Fatalf("auth template: %+v", result)460+ }461+}462+463+func TestMCPPermissionAndTransportErrors(t *testing.T) {464+ _, pub := testKey(t)465+ relay := newFakeRelay(t, "", pub)466+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")467+ c.initialize("2025-06-18")468+ cases := map[string]struct {469+ tool string470+ args map[string]any471+ prefix string472+ }{473+ "restricted result": {"list_rooms", map[string]any{"q": "secret"}, "permission: restricted: members only"},474+ "structured message": {"read_room", map[string]any{"id": "rejected"}, "permission: restricted: room is read-only"},475+ "http 401": {"read_room", map[string]any{"id": "unauthorized"}, "permission: auth-required: sign this request"},476+ "http 403": {"read_room", map[string]any{"id": "forbidden"}, "permission: origin is not allowed"},477+ "http 500": {"read_room", map[string]any{"id": "boom"}, "transport: relay HTTP 500"},478+ "plain relay failure": {"read_room", map[string]any{}, ""},479+ }480+ for name, tc := range cases {481+ result, response := c.tool(tc.tool, tc.args)482+ if response.Error != nil {483+ t.Fatalf("%s: JSON-RPC error %+v", name, response.Error)484+ }485+ if tc.prefix == "" {486+ if result.IsError {487+ t.Fatalf("%s: unexpected error %+v", name, result)488+ }489+ continue490+ }491+ if !result.IsError || !strings.HasPrefix(firstText(result), tc.prefix) {492+ t.Fatalf("%s: %+v", name, result)493+ }494+ }495+ result, _ := c.tool("read_room", map[string]any{"id": "huge"})496+ text := firstText(result)497+ if result.IsError || len(text) > mcpTextLimit+100 || !strings.Contains(text, "[truncated: ") {498+ t.Fatalf("huge text not bounded: %d bytes, error %v", len(text), result.IsError)499+ }500+ relay.server.Close()501+ result, response := c.tool("read_room", map[string]any{"id": "lobby"})502+ if response.Error != nil || !result.IsError || !strings.HasPrefix(firstText(result), "transport: ") {503+ t.Fatalf("connection refused: %+v %+v", response.Error, result)504+ }505+}506+507+func TestMCPListFailsWhenRelayIsDown(t *testing.T) {508+ _, pub := testKey(t)509+ relay := newFakeRelay(t, "", pub)510+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")511+ c.initialize("2025-06-18")512+ 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)516+ }517+}518+519+func TestMCPToolsFlag(t *testing.T) {520+ _, pub := testKey(t)521+ relay := newFakeRelay(t, "", pub)522+ c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--tools", "read_room, list_rooms,list_wiki")523+ c.initialize("2025-06-18")524+ if names := c.listNames(); strings.Join(names, ",") != "read_room,list_rooms" {525+ t.Fatalf("--tools table: %v", names)526+ }527+ for _, tc := range []struct{ tools, want string }{528+ {"list_rooms,publish_event", `unknown tool "publish_event"`},529+ {"set_policy", `unknown tool "set_policy"`},530+ {"post_message", "add --allow-writes"},531+ {" , ", "names no tool"},532+ } {533+ err := mcpCommand([]string{"--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY", "--tools", tc.tools}, strings.NewReader(""), io.Discard, io.Discard)534+ if err == nil || !strings.Contains(err.Error(), tc.want) {535+ t.Fatalf("--tools %q: %v", tc.tools, err)536+ }537+ }538+ order, err := mcpAllowlist("post_message,list_rooms", true)539+ if err != nil || strings.Join(order, ",") != "post_message,list_rooms" {540+ t.Fatalf("write tool with --allow-writes: %v %v", order, err)541+ }542+ if err := mcpCommand([]string{"--relay", relay.base(), "--key-env", "TINY_MCP_MISSING"}, strings.NewReader(""), io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "TINY_MCP_MISSING is not set") {543+ t.Fatalf("missing key: %v", err)544+ }545+ if err := run([]string{"mcp"}, strings.NewReader(""), io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "--relay") {546+ t.Fatalf("missing relay: %v", err)547+ }548+}549+550+func TestMCPCatalogNeverNamesManagementTools(t *testing.T) {551+ for _, name := range append(append([]string(nil), mcpReadTools...), mcpWriteTools...) {552+ if name == "publish_event" || name == "set_policy" || name == "read_management" || strings.HasSuffix(name, "_agent") || strings.HasSuffix(name, "_callback") {553+ t.Fatalf("catalog exposes %s", name)554+ }555+ }556+}557+558+// TestMCPAgainstRelayTransport runs the facade against the relay's own MCP559+// transport so the headers, _meta and NIP-98 proof it sends are checked by560+// the same code the daemon uses.561+func TestMCPAgainstRelayTransport(t *testing.T) {562+ _, pub := testKey(t)563+ registry := &mcp.Registry{}564+ var published []nostr.Event565+ must := func(err error) {566+ if err != nil {567+ t.Fatal(err)568+ }569+ }570+ must(registry.Add(mcp.Tool{Name: "list_rooms", Description: "List rooms.", InputSchema: mcp.Object(map[string]any{"q": map[string]any{"type": "string"}}), Handler: func(_ context.Context, call mcp.Call) (mcp.Result, error) {571+ if call.String("q") == "secret" {572+ return mcp.Result{}, errors.New("restricted: members only")573+ }574+ return mcp.Value(map[string]any{"rooms": []any{"lobby"}, "actor": call.Actor}), nil575+ }}))576+ must(registry.Add(mcp.Tool{Name: "post_message", Description: "Post.", InputSchema: mcp.Object(map[string]any{"event": map[string]any{"type": "object"}, "room": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}}), Handler: func(_ context.Context, call mcp.Call) (mcp.Result, error) {577+ if raw, present := call.Arguments["event"]; present {578+ encoded, _ := json.Marshal(raw)579+ e, err := nostr.Parse(encoded)580+ if err == nil {581+ err = nostr.Validate(e)582+ }583+ if err != nil || e.Kind != 9 || e.PubKey != call.Actor {584+ return mcp.Failure("The event is not acceptable.", nil), nil585+ }586+ published = append(published, e)587+ return mcp.Value(map[string]any{"event_id": e.ID, "accepted": true, "message": ""}), nil588+ }589+ return mcp.Value(map[string]any{"unsigned": map[string]any{"kind": 9, "created_at": time.Now().Unix(), "tags": [][]string{{"h", call.String("room")}}, "content": call.String("content")}, "next": "sign"}), nil590+ }}))591+ must(registry.Add(mcp.Tool{Name: "publish_event", Description: "Any event.", InputSchema: mcp.Object(nil), Handler: func(context.Context, mcp.Call) (mcp.Result, error) {592+ t.Fatal("publish_event must never be reached")593+ return mcp.Result{}, nil594+ }}))595+ var server *httptest.Server596+ relay := &mcp.Server{Tools: registry, Info: mcp.Implementation{Name: "tinyrelay", Version: "test"}, Challenge: `Nostr realm="tiny"`, Authenticate: func(r *http.Request, body []byte) (string, error) {597+ fake := &fakeRelay{t: t, pubkey: pub, server: server}598+ if err := fake.checkProof(r, body); err != nil {599+ return "", errors.New("auth-required: " + err.Error())600+ }601+ return pub, nil602+ }}603+ server = httptest.NewServer(relay)604+ t.Cleanup(server.Close)605+ c := startFacade(t, "--relay", server.URL, "--key-env", "TINY_MCP_TEST_KEY", "--allow-writes")606+ c.initialize("2025-03-26")607+ if names := c.listNames(); strings.Join(names, ",") != "list_rooms,post_message" {608+ t.Fatalf("table: %v", names)609+ }610+ result, response := c.tool("list_rooms", map[string]any{})611+ if response.Error != nil || result.IsError || result.StructuredContent.(map[string]any)["actor"] != pub {612+ t.Fatalf("list_rooms: %+v %+v", response.Error, result)613+ }614+ result, _ = c.tool("list_rooms", map[string]any{"q": "secret"})615+ if !result.IsError || firstText(result) != "permission: restricted: members only" {616+ t.Fatalf("restricted: %+v", result)617+ }618+ result, _ = c.tool("list_rooms", map[string]any{"q": 7})619+ if !result.IsError || strings.HasPrefix(firstText(result), "permission:") || strings.HasPrefix(firstText(result), "transport:") {620+ t.Fatalf("schema failure should pass through: %+v", result)621+ }622+ result, _ = c.tool("post_message", map[string]any{"room": "lobby", "content": "hi"})623+ if result.IsError || len(published) != 1 || published[0].Content != "hi" || result.StructuredContent.(map[string]any)["accepted"] != true {624+ t.Fatalf("sign and resubmit: %+v", result)625+ }626+ // The wrong key is a 401 from the transport, reported as a permission error.627+ other, err := nostr.GenerateKey()628+ must(err)629+ t.Setenv("TINY_MCP_OTHER_KEY", other)630+ stranger := startFacade(t, "--relay", server.URL, "--key-env", "TINY_MCP_OTHER_KEY")631+ 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)634+ }635+}