// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio // server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to // document the contract end-to-end (the protocol the internal/plugin client // drives) and to give users a working example to copy. // // Wire it up in reasonix.toml: // // [[plugins]] // name = "example" // command = "reasonix-plugin-example" // // Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount", // its prompt as the "/mcp__example__review" slash command, and its resource as // the "@example:doc://style-guide" reference. // // Protocol, one JSON object per line: // - initialize → {protocolVersion, capabilities, serverInfo} // - notifications/initialized (notification, no id) → ignored // - tools/list → {tools: [{name, description, inputSchema, annotations}]} // - tools/call {name, arguments} → {content: [{type:"text", text}], isError} // - prompts/list → {prompts: [{name, description, arguments}]} // - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]} // - resources/list → {resources: [{uri, name, description, mimeType}]} // - resources/read {uri} → {contents: [{uri, mimeType, text}]} // // Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is // reserved for JSON-RPC so it must never carry stray prose. package main import ( "bufio" "encoding/json" "fmt" "log" "os" "strings" "unicode/utf8" ) // version is overridable via -ldflags "-X main.version=...". Reported in // initialize's serverInfo so reasonix (and humans) can see which build is running. var version = "dev" func main() { log.SetPrefix("reasonix-plugin-example: ") log.SetFlags(0) if err := serve(os.Stdin, os.Stdout); err != nil { log.Fatal(err) } } // --- JSON-RPC framing --- type request struct { JSONRPC string `json:"jsonrpc"` ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise Method string `json:"method"` Params json.RawMessage `json:"params"` } type response struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } type rpcError struct { Code int `json:"code"` Message string `json:"message"` } const ( protocolVersion = "2024-11-05" codeMethodNotFound = -32601 codeInvalidParams = -32602 ) // serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the // pipe / is shutting down). Each line is one JSON-RPC message. func serve(in *os.File, out *os.File) error { r := bufio.NewReader(in) w := bufio.NewWriter(out) defer w.Flush() for { line, err := r.ReadBytes('\n') if len(line) > 0 { if rerr := handleLine(line, w); rerr != nil { return rerr } if ferr := w.Flush(); ferr != nil { return ferr } } if err != nil { return nil // EOF or pipe closed: clean shutdown } } } func handleLine(line []byte, w *bufio.Writer) error { line = trimSpace(line) if len(line) == 0 { return nil } var req request if err := json.Unmarshal(line, &req); err != nil { log.Printf("skipping unparseable line: %v", err) return nil } if req.ID == nil { return nil // notification (e.g. notifications/initialized): no reply } resp := response{JSONRPC: "2.0", ID: *req.ID} switch req.Method { case "initialize": resp.Result = map[string]any{ "protocolVersion": protocolVersion, "capabilities": map[string]any{ "tools": map[string]any{}, "prompts": map[string]any{}, "resources": map[string]any{}, }, "serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version}, } case "tools/list": resp.Result = map[string]any{"tools": toolList()} case "tools/call": resp.Result, resp.Error = callTool(req.Params) case "prompts/list": resp.Result = map[string]any{"prompts": promptList()} case "prompts/get": resp.Result, resp.Error = getPrompt(req.Params) case "resources/list": resp.Result = map[string]any{"resources": resourceList()} case "resources/read": resp.Result, resp.Error = readResource(req.Params) default: resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method} } b, err := json.Marshal(resp) if err != nil { return fmt.Errorf("marshal response: %w", err) } if _, err := w.Write(append(b, '\n')); err != nil { return err } return nil } // --- tools --- // toolDef is one exposed tool: its advertised metadata plus the handler. run // returns the text result, or an error which becomes an isError tool result the // model can read and adapt to. type toolDef struct { name string description string schema map[string]any readOnly bool run func(args map[string]any) (string, error) } // tools is the registry. Both demo tools are read-only and declare it via the // readOnlyHint annotation, so reasonix runs them in parallel batches and (with the // permission layer) auto-allows them without prompting. var tools = []toolDef{ { name: "echo", description: "Echo the given text back. The simplest possible proof the plugin round-trip works.", readOnly: true, schema: map[string]any{ "type": "object", "properties": map[string]any{ "text": map[string]any{"type": "string", "description": "Text to echo back"}, }, "required": []string{"text"}, }, run: func(args map[string]any) (string, error) { text, ok := args["text"].(string) if !ok { return "", fmt.Errorf("argument 'text' must be a string") } return text, nil }, }, { name: "wordcount", description: "Count the lines, words, and bytes of the given text (like wc).", readOnly: true, schema: map[string]any{ "type": "object", "properties": map[string]any{ "text": map[string]any{"type": "string", "description": "Text to measure"}, }, "required": []string{"text"}, }, run: func(args map[string]any) (string, error) { text, ok := args["text"].(string) if !ok { return "", fmt.Errorf("argument 'text' must be a string") } return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d", countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil }, }, } func toolList() []map[string]any { out := make([]map[string]any, 0, len(tools)) for _, t := range tools { out = append(out, map[string]any{ "name": t.name, "description": t.description, "inputSchema": t.schema, "annotations": map[string]any{ "readOnlyHint": t.readOnly, "title": t.name, }, }) } return out } // callTool dispatches a tools/call. A handler error is reported as an isError // content result (in-band, so the model sees it) rather than a JSON-RPC error; // JSON-RPC errors are reserved for protocol-level faults (bad params, unknown // tool). func callTool(params json.RawMessage) (any, *rpcError) { var p struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } if err := json.Unmarshal(params, &p); err != nil { return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} } for _, t := range tools { if t.name != p.Name { continue } text, err := t.run(p.Arguments) if err != nil { return textResult(err.Error(), true), nil } return textResult(text, false), nil } return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name} } func textResult(text string, isError bool) map[string]any { return map[string]any{ "content": []map[string]any{{"type": "text", "text": text}}, "isError": isError, } } // --- prompts --- // promptList advertises the server's prompts. They surface in reasonix as // /mcp__example__ slash commands. func promptList() []map[string]any { return []map[string]any{{ "name": "review", "description": "Draft a focused code-review request for a file.", "arguments": []map[string]any{ {"name": "path", "description": "File to review", "required": true}, }, }} } // getPrompt renders a prompt into MCP messages. The returned text becomes the // next user turn in reasonix, so it's phrased as an instruction to the model. func getPrompt(params json.RawMessage) (any, *rpcError) { var p struct { Name string `json:"name"` Arguments map[string]string `json:"arguments"` } if err := json.Unmarshal(params, &p); err != nil { return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} } if p.Name != "review" { return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name} } path := p.Arguments["path"] if path == "" { path = "the current file" } text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path) return map[string]any{ "description": "Code review request", "messages": []map[string]any{ {"role": "user", "content": map[string]any{"type": "text", "text": text}}, }, }, nil } // --- resources --- // resourceContents is the demo resource store, keyed by uri. var resourceContents = map[string]string{ "doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.", } func resourceList() []map[string]any { return []map[string]any{{ "uri": "doc://style-guide", "name": "Style guide", "description": "The project's coding style notes.", "mimeType": "text/plain", }} } func readResource(params json.RawMessage) (any, *rpcError) { var p struct { URI string `json:"uri"` } if err := json.Unmarshal(params, &p); err != nil { return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} } text, ok := resourceContents[p.URI] if !ok { return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI} } return map[string]any{ "contents": []map[string]any{ {"uri": p.URI, "mimeType": "text/plain", "text": text}, }, }, nil } // countLines counts newline-separated lines, counting a final unterminated line. func countLines(s string) int { if s == "" { return 0 } n := strings.Count(s, "\n") if !strings.HasSuffix(s, "\n") { n++ } return n } // trimSpace trims leading/trailing ASCII whitespace without pulling in bytes. func trimSpace(b []byte) []byte { return []byte(strings.TrimSpace(string(b))) }