1package main23import (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"1819 "github.com/FelineStateMachine/tinyrelay/internal/mcp"20 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"21 "github.com/FelineStateMachine/tinyrelay/tinyagent/client"22)2324// 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, plus27// tiny_diagnose, which the facade answers itself; publish_event and the28// management tools never are.2930// mcpReadTools are exposed by default.31var mcpReadTools = []string{32 "list_repositories", "read_repository",33 "list_issues", "read_issue",34 "list_pull_requests", "read_pull_request",35 "list_files", "read_file", "read_attachment",36 "list_rooms", "read_room", "read_thread",37 "list_wiki", "read_wiki_page",38 "list_jobs", "read_job",39 "read_status",40}4142// mcpWriteTools are added with --allow-writes.43var mcpWriteTools = []string{44 "create_issue", "create_pull_request", "comment",45 "post_message", "start_thread", "reply_in_thread", "react",46 "publish_wiki_page", "propose_wiki_merge",47 "upload_attachment",48 "request_decision", "request_grant",49 "request_job", "accept_job", "job_progress", "job_result", "job_error", "cancel_job",50}5152// mcpHandshakeVersions are the revisions the official SDK's stdio client may53// send in initialize. Any other request is answered with mcpFallbackVersion.54var mcpHandshakeVersions = []string{"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}5556const (57 mcpFallbackVersion = "2025-06-18"58 mcpTextLimit = 64 << 1059 mcpStructuredLimit = 256 << 1060 mcpCallTimeout = 2 * time.Minute61 mcpPermission = "permission: "62 mcpTransport = "transport: "63 mcpUnexpectedUnsign = "the relay returned an unsigned event this tool is not expected to publish"64)6566// mcpWriteKinds names the event kinds each write tool publishes, taken from67// the relay's tool builders. The facade signs a template only for a tool in68// this map and only when the template's kind is listed for it; anything69// else is passed through unsigned. upload_attachment stores a file and70// publishes nothing, so it maps to no kind.71var mcpWriteKinds = map[string][]int{72 "create_issue": {1621},73 "create_pull_request": {1618},74 "comment": {1111},75 "post_message": {9},76 "start_thread": {11},77 "reply_in_thread": {12},78 "react": {7},79 "publish_wiki_page": {30818},80 "propose_wiki_merge": {818},81 "upload_attachment": {},82 "request_decision": {9, 1111},83 "request_grant": {1111},84 "request_job": {43001},85 "accept_job": {43002},86 "job_progress": {43003},87 "job_result": {43004},88 "job_error": {43006},89 "cancel_job": {43005},90}9192// mcpAuthKinds are never signed on the relay's behalf: a template of one of93// these kinds would be an authorization, not a publication.94var mcpAuthKinds = map[int]bool{22242: true, 24242: true, 27235: true}9596// mcpDiagnoseTool is the one tool the facade answers itself. It is always97// offered, whatever the allowlist, because it is how an agent finds out why98// the other tools fail. No relay tool carries this name.99const mcpDiagnoseTool = "tiny_diagnose"100101var mcpDiagnoseEntry = map[string]any{102 "name": mcpDiagnoseTool,103 "title": "Diagnose relay access",104 "description": "Check this connector's access to the relay: reachability, whether the relay accepts its signature, membership, the agent grant and its state, and which of the given rooms and kinds the grant lacks. The verdict is ok, needs-grant, not-a-member, unauthorized, unreachable or error; advice says what to do next, such as calling request_grant.",105 "inputSchema": map[string]any{106 "type": "object",107 "properties": map[string]any{108 "rooms": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Room ids the connector needs to post in."},109 "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "description": "Event kinds the connector needs to publish."},110 },111 },112 "annotations": map[string]any{"readOnlyHint": true, "idempotentHint": true},113}114115type mcpFacade struct {116 client *client.Client117 secret string118 writes bool119 allowed map[string]bool120 order []string121 info mcp.Implementation122 stderr io.Writer123 relayMu sync.Mutex124 tableMu sync.Mutex125 table []map[string]any126 outMu sync.Mutex127}128129// mcpToolResult is the subset of a tools/call result the facade relays.130type mcpToolResult struct {131 Content []map[string]any `json:"content"`132 StructuredContent any `json:"structuredContent,omitempty"`133 IsError bool `json:"isError"`134}135136func mcpCommand(args []string, in io.Reader, out, stderr io.Writer) error {137 fs := flag.NewFlagSet("mcp", flag.ContinueOnError)138 fs.SetOutput(io.Discard)139 relay := fs.String("relay", "", "relay URL")140 keyEnv := fs.String("key-env", "TINY_PRIVATE_KEY", "private key environment variable")141 tools := fs.String("tools", "", "comma-separated tool names to expose")142 writes := fs.Bool("allow-writes", false, "expose write tools")143 if err := fs.Parse(args); err != nil {144 return err145 }146 if *relay == "" {147 return errors.New("usage: tinyagent mcp --relay URL [--key-env TINY_PRIVATE_KEY] [--tools name,name] [--allow-writes]")148 }149 secret := os.Getenv(*keyEnv)150 if secret == "" {151 return fmt.Errorf("%s is not set", *keyEnv)152 }153 c, err := client.New(*relay, secret)154 if err != nil {155 return err156 }157 order, err := mcpAllowlist(*tools, *writes)158 if err != nil {159 return err160 }161 f := newMCPFacade(c, secret, *writes, order, stderr)162 fmt.Fprintf(stderr, "tinyagent mcp: relay %s, %d tools\n", c.BaseURL, len(order))163 return f.serve(in, out)164}165166func newMCPFacade(c *client.Client, secret string, writes bool, order []string, stderr io.Writer) *mcpFacade {167 allowed := make(map[string]bool, len(order))168 for _, name := range order {169 allowed[name] = true170 }171 return &mcpFacade{client: c, secret: secret, writes: writes, allowed: allowed, order: order, info: mcp.Implementation{Name: "tinyagent", Version: tinyagentVersion()}, stderr: stderr}172}173174// mcpAllowlist resolves the exposed tool names. Without --tools the read175// tools are exposed, plus the write tools with --allow-writes. A --tools176// list may name any curated tool; write tools in it still need177// --allow-writes.178func mcpAllowlist(spec string, writes bool) ([]string, error) {179 reads := map[string]bool{}180 for _, name := range mcpReadTools {181 reads[name] = true182 }183 writeSet := map[string]bool{}184 for _, name := range mcpWriteTools {185 writeSet[name] = true186 }187 if strings.TrimSpace(spec) == "" {188 order := append([]string(nil), mcpReadTools...)189 if writes {190 order = append(order, mcpWriteTools...)191 }192 return order, nil193 }194 var order []string195 seen := map[string]bool{}196 for _, raw := range strings.Split(spec, ",") {197 name := strings.TrimSpace(raw)198 if name == "" || seen[name] {199 continue200 }201 switch {202 case reads[name]:203 case writeSet[name]:204 if !writes {205 return nil, fmt.Errorf("tool %s publishes events; add --allow-writes to expose it", name)206 }207 default:208 return nil, fmt.Errorf("unknown tool %q in --tools", name)209 }210 seen[name] = true211 order = append(order, name)212 }213 if len(order) == 0 {214 return nil, errors.New("--tools names no tool")215 }216 return order, nil217}218219func tinyagentVersion() string {220 if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {221 return info.Main.Version222 }223 return "dev"224}225226// serve reads newline-delimited JSON-RPC messages from in and answers on227// out. Requests run concurrently so pings are answered while a relay call228// is in flight; relay calls themselves are serialized. Notifications,229// including cancellations, are ignored.230func (f *mcpFacade) serve(in io.Reader, out io.Writer) error {231 scan := bufio.NewScanner(in)232 scan.Buffer(make([]byte, 4096), 48<<20)233 ctx, cancel := context.WithCancel(context.Background())234 defer cancel()235 var wg sync.WaitGroup236 for scan.Scan() {237 line := bytes.TrimSpace(scan.Bytes())238 if len(line) == 0 {239 continue240 }241 var req mcp.Request242 if err := json.Unmarshal(line, &req); err != nil || !bytes.HasPrefix(line, []byte("{")) {243 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"}})244 continue245 }246 if req.Notification() || string(bytes.TrimSpace(req.ID)) == "null" {247 continue248 }249 wg.Add(1)250 go func(req mcp.Request) {251 defer wg.Done()252 f.write(out, f.handle(ctx, req))253 }(req)254 }255 wg.Wait()256 return scan.Err()257}258259func (f *mcpFacade) write(out io.Writer, response mcp.Response) {260 f.outMu.Lock()261 defer f.outMu.Unlock()262 raw, err := json.Marshal(response)263 if err != nil {264 fmt.Fprintf(f.stderr, "tinyagent mcp: encode response: %v\n", err)265 return266 }267 _, _ = out.Write(append(raw, '\n'))268}269270func (f *mcpFacade) handle(ctx context.Context, req mcp.Request) mcp.Response {271 response := mcp.Response{JSONRPC: "2.0", ID: req.ID}272 if req.JSONRPC != "2.0" || req.Method == "" {273 response.Error = &mcp.Error{Code: mcp.CodeInvalidRequest, Message: "jsonrpc must be \"2.0\" and method is required"}274 return response275 }276 var result any277 var rpcErr *mcp.Error278 switch req.Method {279 case "initialize":280 result = f.initialize(req.Params)281 case "ping":282 result = struct{}{}283 case "tools/list":284 result, rpcErr = f.list(ctx)285 case "tools/call":286 result, rpcErr = f.call(ctx, req.Params)287 default:288 rpcErr = &mcp.Error{Code: mcp.CodeMethodNotFound, Message: "Method not found: " + req.Method}289 }290 if rpcErr != nil {291 response.Error = rpcErr292 return response293 }294 response.Result = result295 return response296}297298func (f *mcpFacade) initialize(params json.RawMessage) map[string]any {299 var p struct {300 ProtocolVersion string `json:"protocolVersion"`301 }302 _ = json.Unmarshal(params, &p)303 version := mcpFallbackVersion304 for _, known := range mcpHandshakeVersions {305 if p.ProtocolVersion == known {306 version = known307 }308 }309 return map[string]any{310 "protocolVersion": version,311 "capabilities": map[string]any{"tools": map[string]any{}},312 "serverInfo": f.info,313 }314}315316// list answers tools/list. When the relay's table cannot be loaded the317// answer is the local tiny_diagnose tool alone, so the one tool that318// explains the outage is offered during it; the upstream failure goes to319// stderr and the table is fetched again on the next list or call.320func (f *mcpFacade) list(ctx context.Context) (map[string]any, *mcp.Error) {321 tools, err := f.tools(ctx)322 if err != nil {323 fmt.Fprintf(f.stderr, "tinyagent mcp: tools/list: %s; offering %s only\n", f.describe(err), mcpDiagnoseTool)324 return map[string]any{"tools": []map[string]any{mcpDiagnoseEntry}}, nil325 }326 return map[string]any{"tools": tools}, nil327}328329// loaded reports whether the relay's tool table is held.330func (f *mcpFacade) loaded() bool {331 f.tableMu.Lock()332 defer f.tableMu.Unlock()333 return f.table != nil334}335336// tools returns the relay's tool table restricted to the allowlist, in337// allowlist order, with the relay's descriptions and schemas. The table is338// fetched once per process and again after an unknown-tool error or a339// failed fetch.340func (f *mcpFacade) tools(ctx context.Context) ([]map[string]any, error) {341 f.tableMu.Lock()342 defer f.tableMu.Unlock()343 if f.table != nil {344 return f.table, nil345 }346 raw, err := f.relay(ctx, "tools/list", map[string]any{})347 if err != nil {348 return nil, err349 }350 var listed struct {351 Tools []map[string]any `json:"tools"`352 }353 if err := json.Unmarshal(raw, &listed); err != nil {354 return nil, errors.New("relay returned an invalid tool table")355 }356 byName := map[string]map[string]any{}357 for _, tool := range listed.Tools {358 if name, _ := tool["name"].(string); name != "" {359 byName[name] = tool360 }361 }362 table := []map[string]any{}363 for _, name := range f.order {364 tool, ok := byName[name]365 if !ok {366 continue367 }368 entry := map[string]any{"name": name}369 for _, key := range []string{"title", "description", "inputSchema", "outputSchema", "annotations"} {370 if value, ok := tool[key]; ok && value != nil {371 entry[key] = value372 }373 }374 if _, ok := entry["inputSchema"]; !ok {375 entry["inputSchema"] = map[string]any{"type": "object"}376 }377 table = append(table, entry)378 }379 table = append(table, mcpDiagnoseEntry)380 f.table = table381 return table, nil382}383384func (f *mcpFacade) invalidate() {385 f.tableMu.Lock()386 f.table = nil387 f.tableMu.Unlock()388}389390func (f *mcpFacade) call(ctx context.Context, params json.RawMessage) (any, *mcp.Error) {391 var p struct {392 Name string `json:"name"`393 Arguments map[string]any `json:"arguments"`394 }395 if len(params) > 0 {396 if err := json.Unmarshal(params, &p); err != nil {397 return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "params must be an object with name and arguments"}398 }399 }400 if p.Name == "" {401 return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "params.name is required"}402 }403 if p.Name == mcpDiagnoseTool {404 return f.diagnose(ctx, p.Arguments), nil405 }406 if !f.allowed[p.Name] {407 return nil, &mcp.Error{Code: mcp.CodeInvalidParams, Message: "Unknown tool: " + p.Name}408 }409 if p.Arguments == nil {410 p.Arguments = map[string]any{}411 }412 result := f.forward(ctx, p.Name, p.Arguments)413 if template, ok := mcpUnsignedTemplate(result); ok {414 result = f.signAndResubmit(ctx, p.Name, p.Arguments, template, result)415 }416 if !f.loaded() && !(result.IsError && strings.HasPrefix(firstTextBlock(result), mcpTransport)) {417 // The relay answered, so a table that failed to load earlier is418 // fetched again now rather than on the next tools/list.419 if _, err := f.tools(ctx); err != nil {420 fmt.Fprintf(f.stderr, "tinyagent mcp: tools/list: %s\n", f.describe(err))421 }422 }423 return mcpBound(mcpPermissionError(result)), nil424}425426func firstTextBlock(result mcpToolResult) string {427 for _, block := range result.Content {428 if text, ok := block["text"].(string); ok && block["type"] == "text" {429 return text430 }431 }432 return ""433}434435// forward runs one tools/call on the relay and turns every failure into a436// tool result the model can read.437func (f *mcpFacade) forward(ctx context.Context, name string, arguments map[string]any) mcpToolResult {438 raw, err := f.relay(ctx, "tools/call", map[string]any{"name": name, "arguments": arguments})439 if err != nil {440 var relayErr *client.MCPError441 if errors.As(err, &relayErr) && relayErr.Code == mcp.CodeInvalidParams && strings.HasPrefix(relayErr.Message, "Unknown tool") {442 f.invalidate()443 }444 return mcpFailure(f.describe(err))445 }446 var result mcpToolResult447 if err := json.Unmarshal(raw, &result); err != nil {448 return mcpFailure(mcpTransport + "the relay returned an invalid tool result")449 }450 if result.Content == nil {451 result.Content = []map[string]any{}452 }453 return result454}455456// describe classifies a relay failure: HTTP 401 and 403 are permission457// errors, unreachable relays and 5xx answers are transport errors, and any458// other JSON-RPC error is reported with the relay's message.459func (f *mcpFacade) describe(err error) string {460 var relayErr *client.MCPError461 if !errors.As(err, &relayErr) {462 return mcpTransport + err.Error()463 }464 switch {465 case relayErr.Status == 401 || relayErr.Status == 403:466 return mcpPermission + relayErr.Message467 case relayErr.Status >= 500:468 return mcpTransport + relayErr.Error()469 default:470 return "relay: " + relayErr.Message471 }472}473474// relay serializes calls to the relay and bounds each one.475func (f *mcpFacade) relay(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {476 f.relayMu.Lock()477 defer f.relayMu.Unlock()478 ctx, cancel := context.WithTimeout(ctx, mcpCallTimeout)479 defer cancel()480 return f.client.MCP(ctx, method, params, f.info)481}482483// mcpUnsignedTemplate recognizes a write tool's first answer: a successful484// result whose structuredContent.unsigned carries kind, tags and content.485func mcpUnsignedTemplate(result mcpToolResult) (nostr.Event, bool) {486 if result.IsError {487 return nostr.Event{}, false488 }489 structured, ok := result.StructuredContent.(map[string]any)490 if !ok {491 return nostr.Event{}, false492 }493 unsigned, ok := structured["unsigned"].(map[string]any)494 if !ok {495 return nostr.Event{}, false496 }497 kind, ok := unsigned["kind"].(float64)498 if !ok || kind < 0 || kind != float64(int(kind)) {499 return nostr.Event{}, false500 }501 content, ok := unsigned["content"].(string)502 if !ok {503 return nostr.Event{}, false504 }505 rawTags, ok := unsigned["tags"].([]any)506 if !ok {507 return nostr.Event{}, false508 }509 tags := make([][]string, 0, len(rawTags))510 for _, rawTag := range rawTags {511 values, ok := rawTag.([]any)512 if !ok {513 return nostr.Event{}, false514 }515 tag := make([]string, 0, len(values))516 for _, value := range values {517 text, ok := value.(string)518 if !ok {519 return nostr.Event{}, false520 }521 tag = append(tag, text)522 }523 tags = append(tags, tag)524 }525 createdAt, _ := unsigned["created_at"].(float64)526 return nostr.Event{Kind: int(kind), CreatedAt: int64(createdAt), Tags: tags, Content: content}, true527}528529// signAndResubmit signs the relay's template as returned, with no change to530// its kind, tags or content, and calls the same tool again with the signed531// event. The relay's publish result is what the model sees.532//533// The signature is the boundary: it is given only with --allow-writes, only534// to a curated write tool, and only for a kind that tool is expected to535// publish. Any other template is handed back unsigned as an error that536// still carries the relay's result, so the model sees what came back and537// nothing is signed.538func (f *mcpFacade) signAndResubmit(ctx context.Context, name string, arguments map[string]any, template nostr.Event, original mcpToolResult) mcpToolResult {539 if mcpAuthKinds[template.Kind] {540 return mcpFailure(fmt.Sprintf("refused to sign a kind %d authorization event returned by %s", template.Kind, name))541 }542 if !f.writes || !mcpExpectedKind(name, template.Kind) {543 return mcpFailureWith(fmt.Sprintf("%s: kind %d from %s", mcpUnexpectedUnsign, template.Kind, name), original.StructuredContent)544 }545 if template.CreatedAt == 0 {546 template.CreatedAt = time.Now().Unix()547 }548 if err := nostr.Sign(&template, f.secret); err != nil {549 return mcpFailure("sign: " + err.Error())550 }551 resubmit := make(map[string]any, len(arguments)+1)552 for k, v := range arguments {553 resubmit[k] = v554 }555 resubmit["event"] = template556 return f.forward(ctx, name, resubmit)557}558559// mcpExpectedKind reports whether name is a curated write tool that560// publishes kind.561func mcpExpectedKind(name string, kind int) bool {562 for _, expected := range mcpWriteKinds[name] {563 if expected == kind {564 return true565 }566 }567 return false568}569570// mcpPermissionError marks relay refusals so the agent can ask for a grant.571func mcpPermissionError(result mcpToolResult) mcpToolResult {572 if !result.IsError {573 return result574 }575 if structured, ok := result.StructuredContent.(map[string]any); ok {576 if message, _ := structured["message"].(string); mcpRestricted(message) {577 return mcpFailureWith(mcpPermission+message, result.StructuredContent)578 }579 }580 for i, block := range result.Content {581 text, _ := block["text"].(string)582 if block["type"] == "text" && mcpRestricted(text) {583 result.Content[i]["text"] = mcpPermission + text584 }585 }586 return result587}588589func mcpRestricted(message string) bool {590 return strings.HasPrefix(message, "restricted:") || strings.HasPrefix(message, "auth-required:")591}592593// mcpBound caps each text block at mcpTextLimit bytes and structuredContent594// at mcpStructuredLimit bytes of JSON, and says so. Oversized structured595// content is replaced by {"truncated": true, "bytes": N} with a note in596// the text block, which the cap leaves room for.597func mcpBound(result mcpToolResult) mcpToolResult {598 for i, block := range result.Content {599 text, ok := block["text"].(string)600 if !ok || len(text) <= mcpTextLimit {601 continue602 }603 cut := mcpTextLimit604 for cut > 0 && !utf8.RuneStart(text[cut]) {605 cut--606 }607 result.Content[i]["text"] = text[:cut] + fmt.Sprintf("\n[truncated: %d of %d bytes shown]", cut, len(text))608 }609 if result.StructuredContent == nil {610 return result611 }612 raw, err := json.Marshal(result.StructuredContent)613 if err != nil || len(raw) <= mcpStructuredLimit {614 return result615 }616 result.StructuredContent = map[string]any{"truncated": true, "bytes": len(raw)}617 note := fmt.Sprintf("[structuredContent truncated: %d bytes exceeds the %d byte limit]", len(raw), mcpStructuredLimit)618 for i, block := range result.Content {619 if text, ok := block["text"].(string); ok && block["type"] == "text" {620 result.Content[i]["text"] = text + "\n" + note621 return result622 }623 }624 result.Content = append(result.Content, map[string]any{"type": "text", "text": note})625 return result626}627628func mcpFailure(message string) mcpToolResult {629 return mcpFailureWith(message, nil)630}631632func mcpFailureWith(message string, structured any) mcpToolResult {633 return mcpToolResult{Content: []map[string]any{{"type": "text", "text": message}}, StructuredContent: structured, IsError: true}634}