chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
// 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__<name> 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user