package mcp import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "git.d-ma.be/mathias/gitea-mcp/internal/registry" ) // ServeStdio runs the MCP JSON-RPC protocol over stdio: one JSON-RPC message // per line on in, one response per line on out — the standard MCP stdio // framing, and how Claude Code spawns a LOCAL MCP server as a subprocess. // // Built for gitea-admin-mcp (see cmd/gitea-admin-mcp), which has to run // locally where real admin credentials exist just-in-time, rather than // through the hosted HTTP server (server.go) a sandboxed agent session talks // to. The method dispatch below intentionally mirrors handlePOST in // server.go — same three methods, same error codes — so a tool written // against registry.Tool behaves identically over either transport; it is // kept as a separate small loop rather than a shared refactor of the HTTP // handler, which mixes in http.ResponseWriter specifics this transport // doesn't have. func ServeStdio(ctx context.Context, reg *registry.Registry, in io.Reader, out io.Writer) error { scanner := bufio.NewScanner(in) scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) // 1 MiB max line, matches server.go's request cap enc := json.NewEncoder(out) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } var req Request if err := json.Unmarshal(line, &req); err != nil { if werr := enc.Encode(NewErrorResponse(nil, -32700, "parse error", nil)); werr != nil { return werr } continue } if req.ID == nil { continue // notification — no response, per JSON-RPC 2.0 } resp := dispatch(ctx, reg, req) if err := enc.Encode(resp); err != nil { return err } } return scanner.Err() } // dispatch handles one already-parsed request. Mirrors server.go's // handlePOST switch exactly (initialize / tools/list / tools/call / unknown). func dispatch(ctx context.Context, reg *registry.Registry, req Request) Response { if req.Method == "initialize" { return NewResponse(req.ID, map[string]any{ "protocolVersion": ProtocolVersion, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "gitea-admin-mcp", "version": "0.1.0"}, }) } switch req.Method { case "tools/list": return NewResponse(req.ID, map[string]any{"tools": reg.Tools()}) case "tools/call": var p struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } if err := json.Unmarshal(req.Params, &p); err != nil { return NewErrorResponse(req.ID, -32602, "invalid params", nil) } out, err := reg.Dispatch(ctx, p.Name, p.Arguments) if err != nil { code := -32000 if errors.Is(err, registry.ErrToolNotFound) { code = CodeNotFound } return NewErrorResponse(req.ID, code, err.Error(), nil) } return NewResponse(req.ID, map[string]any{ "content": []map[string]any{{"type": "text", "text": string(out)}}, }) default: return NewErrorResponse(req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method), nil) } }