返回 DeepSeek-Reasonix
main.go
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
350 lines GO