| 1 | // Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio |
| 2 | // server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to |
| 3 | // document the contract end-to-end (the protocol the internal/plugin client |
| 4 | // drives) and to give users a working example to copy. |
| 5 | // |
| 6 | // Wire it up in reasonix.toml: |
| 7 | // |
| 8 | // [[plugins]] |
| 9 | // name = "example" |
| 10 | // command = "reasonix-plugin-example" |
| 11 | // |
| 12 | // Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount", |
| 13 | // its prompt as the "/mcp__example__review" slash command, and its resource as |
| 14 | // the "@example:doc://style-guide" reference. |
| 15 | // |
| 16 | // Protocol, one JSON object per line: |
| 17 | // - initialize → {protocolVersion, capabilities, serverInfo} |
| 18 | // - notifications/initialized (notification, no id) → ignored |
| 19 | // - tools/list → {tools: [{name, description, inputSchema, annotations}]} |
| 20 | // - tools/call {name, arguments} → {content: [{type:"text", text}], isError} |
| 21 | // - prompts/list → {prompts: [{name, description, arguments}]} |
| 22 | // - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]} |
| 23 | // - resources/list → {resources: [{uri, name, description, mimeType}]} |
| 24 | // - resources/read {uri} → {contents: [{uri, mimeType, text}]} |
| 25 | // |
| 26 | // Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is |
| 27 | // reserved for JSON-RPC so it must never carry stray prose. |
| 28 | package main |
| 29 | |
| 30 | import ( |
| 31 | "bufio" |
| 32 | "encoding/json" |
| 33 | "fmt" |
| 34 | "log" |
| 35 | "os" |
| 36 | "strings" |
| 37 | "unicode/utf8" |
| 38 | ) |
| 39 | |
| 40 | // version is overridable via -ldflags "-X main.version=...". Reported in |
| 41 | // initialize's serverInfo so reasonix (and humans) can see which build is running. |
| 42 | var version = "dev" |
| 43 | |
| 44 | func main() { |
| 45 | log.SetPrefix("reasonix-plugin-example: ") |
| 46 | log.SetFlags(0) |
| 47 | if err := serve(os.Stdin, os.Stdout); err != nil { |
| 48 | log.Fatal(err) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // --- JSON-RPC framing --- |
| 53 | |
| 54 | type request struct { |
| 55 | JSONRPC string `json:"jsonrpc"` |
| 56 | ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise |
| 57 | Method string `json:"method"` |
| 58 | Params json.RawMessage `json:"params"` |
| 59 | } |
| 60 | |
| 61 | type response struct { |
| 62 | JSONRPC string `json:"jsonrpc"` |
| 63 | ID json.RawMessage `json:"id"` |
| 64 | Result any `json:"result,omitempty"` |
| 65 | Error *rpcError `json:"error,omitempty"` |
| 66 | } |
| 67 | |
| 68 | type rpcError struct { |
| 69 | Code int `json:"code"` |
| 70 | Message string `json:"message"` |
| 71 | } |
| 72 | |
| 73 | const ( |
| 74 | protocolVersion = "2024-11-05" |
| 75 | codeMethodNotFound = -32601 |
| 76 | codeInvalidParams = -32602 |
| 77 | ) |
| 78 | |
| 79 | // serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the |
| 80 | // pipe / is shutting down). Each line is one JSON-RPC message. |
| 81 | func serve(in *os.File, out *os.File) error { |
| 82 | r := bufio.NewReader(in) |
| 83 | w := bufio.NewWriter(out) |
| 84 | defer w.Flush() |
| 85 | |
| 86 | for { |
| 87 | line, err := r.ReadBytes('\n') |
| 88 | if len(line) > 0 { |
| 89 | if rerr := handleLine(line, w); rerr != nil { |
| 90 | return rerr |
| 91 | } |
| 92 | if ferr := w.Flush(); ferr != nil { |
| 93 | return ferr |
| 94 | } |
| 95 | } |
| 96 | if err != nil { |
| 97 | return nil // EOF or pipe closed: clean shutdown |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func handleLine(line []byte, w *bufio.Writer) error { |
| 103 | line = trimSpace(line) |
| 104 | if len(line) == 0 { |
| 105 | return nil |
| 106 | } |
| 107 | var req request |
| 108 | if err := json.Unmarshal(line, &req); err != nil { |
| 109 | log.Printf("skipping unparseable line: %v", err) |
| 110 | return nil |
| 111 | } |
| 112 | if req.ID == nil { |
| 113 | return nil // notification (e.g. notifications/initialized): no reply |
| 114 | } |
| 115 | |
| 116 | resp := response{JSONRPC: "2.0", ID: *req.ID} |
| 117 | switch req.Method { |
| 118 | case "initialize": |
| 119 | resp.Result = map[string]any{ |
| 120 | "protocolVersion": protocolVersion, |
| 121 | "capabilities": map[string]any{ |
| 122 | "tools": map[string]any{}, |
| 123 | "prompts": map[string]any{}, |
| 124 | "resources": map[string]any{}, |
| 125 | }, |
| 126 | "serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version}, |
| 127 | } |
| 128 | case "tools/list": |
| 129 | resp.Result = map[string]any{"tools": toolList()} |
| 130 | case "tools/call": |
| 131 | resp.Result, resp.Error = callTool(req.Params) |
| 132 | case "prompts/list": |
| 133 | resp.Result = map[string]any{"prompts": promptList()} |
| 134 | case "prompts/get": |
| 135 | resp.Result, resp.Error = getPrompt(req.Params) |
| 136 | case "resources/list": |
| 137 | resp.Result = map[string]any{"resources": resourceList()} |
| 138 | case "resources/read": |
| 139 | resp.Result, resp.Error = readResource(req.Params) |
| 140 | default: |
| 141 | resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method} |
| 142 | } |
| 143 | |
| 144 | b, err := json.Marshal(resp) |
| 145 | if err != nil { |
| 146 | return fmt.Errorf("marshal response: %w", err) |
| 147 | } |
| 148 | if _, err := w.Write(append(b, '\n')); err != nil { |
| 149 | return err |
| 150 | } |
| 151 | return nil |
| 152 | } |
| 153 | |
| 154 | // --- tools --- |
| 155 | |
| 156 | // toolDef is one exposed tool: its advertised metadata plus the handler. run |
| 157 | // returns the text result, or an error which becomes an isError tool result the |
| 158 | // model can read and adapt to. |
| 159 | type toolDef struct { |
| 160 | name string |
| 161 | description string |
| 162 | schema map[string]any |
| 163 | readOnly bool |
| 164 | run func(args map[string]any) (string, error) |
| 165 | } |
| 166 | |
| 167 | // tools is the registry. Both demo tools are read-only and declare it via the |
| 168 | // readOnlyHint annotation, so reasonix runs them in parallel batches and (with the |
| 169 | // permission layer) auto-allows them without prompting. |
| 170 | var tools = []toolDef{ |
| 171 | { |
| 172 | name: "echo", |
| 173 | description: "Echo the given text back. The simplest possible proof the plugin round-trip works.", |
| 174 | readOnly: true, |
| 175 | schema: map[string]any{ |
| 176 | "type": "object", |
| 177 | "properties": map[string]any{ |
| 178 | "text": map[string]any{"type": "string", "description": "Text to echo back"}, |
| 179 | }, |
| 180 | "required": []string{"text"}, |
| 181 | }, |
| 182 | run: func(args map[string]any) (string, error) { |
| 183 | text, ok := args["text"].(string) |
| 184 | if !ok { |
| 185 | return "", fmt.Errorf("argument 'text' must be a string") |
| 186 | } |
| 187 | return text, nil |
| 188 | }, |
| 189 | }, |
| 190 | { |
| 191 | name: "wordcount", |
| 192 | description: "Count the lines, words, and bytes of the given text (like wc).", |
| 193 | readOnly: true, |
| 194 | schema: map[string]any{ |
| 195 | "type": "object", |
| 196 | "properties": map[string]any{ |
| 197 | "text": map[string]any{"type": "string", "description": "Text to measure"}, |
| 198 | }, |
| 199 | "required": []string{"text"}, |
| 200 | }, |
| 201 | run: func(args map[string]any) (string, error) { |
| 202 | text, ok := args["text"].(string) |
| 203 | if !ok { |
| 204 | return "", fmt.Errorf("argument 'text' must be a string") |
| 205 | } |
| 206 | return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d", |
| 207 | countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil |
| 208 | }, |
| 209 | }, |
| 210 | } |
| 211 | |
| 212 | func toolList() []map[string]any { |
| 213 | out := make([]map[string]any, 0, len(tools)) |
| 214 | for _, t := range tools { |
| 215 | out = append(out, map[string]any{ |
| 216 | "name": t.name, |
| 217 | "description": t.description, |
| 218 | "inputSchema": t.schema, |
| 219 | "annotations": map[string]any{ |
| 220 | "readOnlyHint": t.readOnly, |
| 221 | "title": t.name, |
| 222 | }, |
| 223 | }) |
| 224 | } |
| 225 | return out |
| 226 | } |
| 227 | |
| 228 | // callTool dispatches a tools/call. A handler error is reported as an isError |
| 229 | // content result (in-band, so the model sees it) rather than a JSON-RPC error; |
| 230 | // JSON-RPC errors are reserved for protocol-level faults (bad params, unknown |
| 231 | // tool). |
| 232 | func callTool(params json.RawMessage) (any, *rpcError) { |
| 233 | var p struct { |
| 234 | Name string `json:"name"` |
| 235 | Arguments map[string]any `json:"arguments"` |
| 236 | } |
| 237 | if err := json.Unmarshal(params, &p); err != nil { |
| 238 | return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} |
| 239 | } |
| 240 | for _, t := range tools { |
| 241 | if t.name != p.Name { |
| 242 | continue |
| 243 | } |
| 244 | text, err := t.run(p.Arguments) |
| 245 | if err != nil { |
| 246 | return textResult(err.Error(), true), nil |
| 247 | } |
| 248 | return textResult(text, false), nil |
| 249 | } |
| 250 | return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name} |
| 251 | } |
| 252 | |
| 253 | func textResult(text string, isError bool) map[string]any { |
| 254 | return map[string]any{ |
| 255 | "content": []map[string]any{{"type": "text", "text": text}}, |
| 256 | "isError": isError, |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // --- prompts --- |
| 261 | |
| 262 | // promptList advertises the server's prompts. They surface in reasonix as |
| 263 | // /mcp__example__<name> slash commands. |
| 264 | func promptList() []map[string]any { |
| 265 | return []map[string]any{{ |
| 266 | "name": "review", |
| 267 | "description": "Draft a focused code-review request for a file.", |
| 268 | "arguments": []map[string]any{ |
| 269 | {"name": "path", "description": "File to review", "required": true}, |
| 270 | }, |
| 271 | }} |
| 272 | } |
| 273 | |
| 274 | // getPrompt renders a prompt into MCP messages. The returned text becomes the |
| 275 | // next user turn in reasonix, so it's phrased as an instruction to the model. |
| 276 | func getPrompt(params json.RawMessage) (any, *rpcError) { |
| 277 | var p struct { |
| 278 | Name string `json:"name"` |
| 279 | Arguments map[string]string `json:"arguments"` |
| 280 | } |
| 281 | if err := json.Unmarshal(params, &p); err != nil { |
| 282 | return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} |
| 283 | } |
| 284 | if p.Name != "review" { |
| 285 | return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name} |
| 286 | } |
| 287 | path := p.Arguments["path"] |
| 288 | if path == "" { |
| 289 | path = "the current file" |
| 290 | } |
| 291 | text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path) |
| 292 | return map[string]any{ |
| 293 | "description": "Code review request", |
| 294 | "messages": []map[string]any{ |
| 295 | {"role": "user", "content": map[string]any{"type": "text", "text": text}}, |
| 296 | }, |
| 297 | }, nil |
| 298 | } |
| 299 | |
| 300 | // --- resources --- |
| 301 | |
| 302 | // resourceContents is the demo resource store, keyed by uri. |
| 303 | var resourceContents = map[string]string{ |
| 304 | "doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.", |
| 305 | } |
| 306 | |
| 307 | func resourceList() []map[string]any { |
| 308 | return []map[string]any{{ |
| 309 | "uri": "doc://style-guide", |
| 310 | "name": "Style guide", |
| 311 | "description": "The project's coding style notes.", |
| 312 | "mimeType": "text/plain", |
| 313 | }} |
| 314 | } |
| 315 | |
| 316 | func readResource(params json.RawMessage) (any, *rpcError) { |
| 317 | var p struct { |
| 318 | URI string `json:"uri"` |
| 319 | } |
| 320 | if err := json.Unmarshal(params, &p); err != nil { |
| 321 | return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} |
| 322 | } |
| 323 | text, ok := resourceContents[p.URI] |
| 324 | if !ok { |
| 325 | return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI} |
| 326 | } |
| 327 | return map[string]any{ |
| 328 | "contents": []map[string]any{ |
| 329 | {"uri": p.URI, "mimeType": "text/plain", "text": text}, |
| 330 | }, |
| 331 | }, nil |
| 332 | } |
| 333 | |
| 334 | // countLines counts newline-separated lines, counting a final unterminated line. |
| 335 | func countLines(s string) int { |
| 336 | if s == "" { |
| 337 | return 0 |
| 338 | } |
| 339 | n := strings.Count(s, "\n") |
| 340 | if !strings.HasSuffix(s, "\n") { |
| 341 | n++ |
| 342 | } |
| 343 | return n |
| 344 | } |
| 345 | |
| 346 | // trimSpace trims leading/trailing ASCII whitespace without pulling in bytes. |
| 347 | func trimSpace(b []byte) []byte { |
| 348 | return []byte(strings.TrimSpace(string(b))) |
| 349 | } |
| 350 |