Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path> # Full index
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"sync"

"github.com/isink17/codegraph/internal/config"
"github.com/isink17/codegraph/internal/versioncheck"
)

type command struct {
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 23 additions & 72 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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()}})
Expand Down Expand Up @@ -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
}

Expand Down
30 changes: 13 additions & 17 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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])
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Loading