diff --git a/README.md b/README.md index e8d0e84..94b6c55 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,26 @@ go install github.com/isink17/codegraph/cmd/codegraph@latest Requires Go 1.23+ and a C compiler (for tree-sitter CGo bindings). +### Check for updates + +```bash +codegraph version-check +``` + +Compares your installed version against the latest GitHub release. If outdated: + +``` +codegraph is not on the latest version. +Current version: v1.0.0 +Latest version: v1.1.0 + +Update with: + go install github.com/isink17/codegraph/cmd/codegraph@latest + +Releases: + https://github.com/isink17/codegraph/releases +``` + ### 2. Auto-configure your AI tool ```bash @@ -272,6 +292,7 @@ See the [`examples/`](examples/) directory for more configuration samples. codegraph install # Auto-configure AI tools codegraph doctor # Check installation health codegraph config show # Show current config +codegraph version-check # Check if a newer release is available # Indexing codegraph index # Full index diff --git a/internal/cli/commands.go b/internal/cli/commands.go index b1697fe..aa69697 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/isink17/codegraph/internal/config" + "github.com/isink17/codegraph/internal/versioncheck" ) type command struct { @@ -393,6 +394,16 @@ func newCommandList() []*command { return runAffectedTests(ctx, cfg, stdout, invokedName, args) }, }, + { + name: "version-check", + aliases: []string{"version_check"}, + description: "check if a newer release is available", + usageLines: []string{" version-check"}, + examples: []string{"codegraph version-check"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, invokedName string, args []string) error { + return versioncheck.RunCheck(ctx, stdout) + }, + }, { name: "visualize", description: "generate interactive graph HTML", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index fb3ea5e..5fd3021 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -2,15 +2,11 @@ package mcp import ( "bufio" - "bytes" "context" "encoding/json" - "errors" "fmt" "io" - "strconv" "strings" - "sync" "github.com/isink17/codegraph/internal/agent" "github.com/isink17/codegraph/internal/config" @@ -75,20 +71,9 @@ type pageRequest struct { var staticToolDefinitions = buildToolDefinitions() -var frameBufferPool = sync.Pool{ - New: func() any { - return &bytes.Buffer{} - }, -} - -type frameProtocolError struct { - msg string -} - -func (e frameProtocolError) Error() string { - return e.msg -} - +// Serve reads JSON-RPC requests from `in` and writes responses to `out`, +// framed per the MCP stdio transport: one JSON object per line, terminated +// by '\n', with no embedded newlines in the JSON itself. func (s *Server) Serve(ctx context.Context, in io.Reader, out, errOut io.Writer) error { reader := bufio.NewReader(in) for { @@ -97,18 +82,11 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, out, errOut io.Writer) if err == io.EOF { return nil } - var protocolErr frameProtocolError - if errors.As(err, &protocolErr) { - if writeErr := writeResponse(out, rpcResponse{ - JSONRPC: "2.0", - Error: &rpcError{Code: -32700, Message: protocolErr.Error()}, - }); writeErr != nil { - return writeErr - } - continue - } return err } + if len(msg) == 0 { + continue + } var req rpcRequest if err := json.Unmarshal(msg, &req); err != nil { writeResponse(out, rpcResponse{JSONRPC: "2.0", Error: &rpcError{Code: -32700, Message: err.Error()}}) @@ -549,62 +527,35 @@ func wrapData(key string, value any, err error) (map[string]any, error) { return map[string]any{"ok": true, "data": map[string]any{key: value}}, nil } +// readFrame reads a single MCP stdio message: one line of JSON terminated +// by '\n' (CRLF tolerated). Empty lines yield an empty payload, which the +// caller skips. EOF is returned when the input is fully consumed. func readFrame(r *bufio.Reader) ([]byte, error) { - contentLength := 0 - sawHeader := false - for { - line, err := r.ReadString('\n') - if err != nil { - if err == io.EOF && !sawHeader { - return nil, io.EOF - } - return nil, err - } - line = strings.TrimRight(line, "\r\n") - if strings.TrimSpace(line) == "" { - break + line, err := r.ReadBytes('\n') + if err != nil { + if err == io.EOF && len(line) == 0 { + return nil, io.EOF } - sawHeader = true - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "content-length:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - return nil, frameProtocolError{msg: "invalid Content-Length header"} - } - v := strings.TrimSpace(parts[1]) - n, err := strconv.Atoi(v) - if err != nil { - return nil, frameProtocolError{msg: "invalid Content-Length value"} - } - if n < 0 { - return nil, frameProtocolError{msg: "negative Content-Length"} - } - contentLength = n + if err != io.EOF { + return nil, err } } - if !sawHeader { - return nil, frameProtocolError{msg: "missing frame headers"} - } - if contentLength <= 0 { - return nil, frameProtocolError{msg: "missing Content-Length header"} - } - payload := make([]byte, contentLength) - if _, err := io.ReadFull(r, payload); err != nil { - return nil, err + trimmed := strings.TrimRight(string(line), "\r\n") + if strings.TrimSpace(trimmed) == "" { + return nil, nil } - return payload, nil + return []byte(trimmed), nil } +// writeResponse marshals resp as compact JSON and writes it followed by a +// single '\n', per MCP stdio framing. func writeResponse(w io.Writer, resp rpcResponse) error { payload, err := json.Marshal(resp) if err != nil { return err } - frame := frameBufferPool.Get().(*bytes.Buffer) - frame.Reset() - defer frameBufferPool.Put(frame) - fmt.Fprintf(frame, "Content-Length: %d\r\n\r\n", len(payload)) - frame.Write(payload) - _, err = w.Write(frame.Bytes()) + payload = append(payload, '\n') + _, err = w.Write(payload) return err } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 9ddc92c..e69872b 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -5,7 +5,6 @@ import ( "bytes" "context" "encoding/json" - "fmt" "io" "os" "path/filepath" @@ -141,7 +140,7 @@ func TestSupportedLanguagesTool(t *testing.T) { } } -func TestServeMalformedFrameReturnsParseErrorAndContinues(t *testing.T) { +func TestServeMalformedJSONReturnsParseErrorAndContinues(t *testing.T) { ctx := context.Background() repoRoot := t.TempDir() s := openTestStore(t) @@ -154,7 +153,8 @@ func TestServeMalformedFrameReturnsParseErrorAndContinues(t *testing.T) { server := NewServer(repoRoot, repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) var input bytes.Buffer - input.WriteString("Content-Length: bad\r\n\r\n") + input.WriteString("not-json\n") + input.WriteString("\n") writeFrameToBuffer(t, &input, map[string]any{ "jsonrpc": "2.0", "id": 1, @@ -166,21 +166,14 @@ func TestServeMalformedFrameReturnsParseErrorAndContinues(t *testing.T) { t.Fatalf("Serve() error = %v", err) } responses := readAllFrames(t, &output) - if len(responses) < 2 { - t.Fatalf("response count = %d, want at least 2", len(responses)) + if len(responses) != 2 { + t.Fatalf("response count = %d, want 2", len(responses)) } - seenError := false - seenResult := false - for _, resp := range responses { - if _, ok := resp["error"]; ok { - seenError = true - } - if _, ok := resp["result"]; ok { - seenResult = true - } + if _, ok := responses[0]["error"]; !ok { + t.Fatalf("first response should be parse error, got: %v", responses[0]) } - if !seenError || !seenResult { - t.Fatalf("expected both error and result responses, got: %v", responses) + if _, ok := responses[1]["result"]; !ok { + t.Fatalf("second response should be initialize result, got: %v", responses[1]) } } @@ -246,8 +239,8 @@ func writeFrameToBuffer(t *testing.T, buf *bytes.Buffer, payload any) { if err != nil { t.Fatalf("json.Marshal() error = %v", err) } - fmt.Fprintf(buf, "Content-Length: %d\r\n\r\n", len(body)) buf.Write(body) + buf.WriteByte('\n') } func readAllFrames(t *testing.T, buf *bytes.Buffer) []map[string]any { @@ -262,6 +255,9 @@ func readAllFrames(t *testing.T, buf *bytes.Buffer) []map[string]any { if err != nil { t.Fatalf("readFrame() error = %v", err) } + if len(body) == 0 { + continue + } var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("json.Unmarshal() error = %v", err)