Skip to content

Instantly share code, notes, and snippets.

@lifthrasiir
Created July 8, 2025 12:10
Show Gist options
  • Save lifthrasiir/32d65c2acb69b3d0eeab92e84c96e07b to your computer and use it in GitHub Desktop.
Save lifthrasiir/32d65c2acb69b3d0eeab92e84c96e07b to your computer and use it in GitHub Desktop.
Linewise MCP (vibe-coded with Gemini)
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io/ioutil"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// ReadLinesParams defines the parameters for the readLines tool.
type ReadLinesParams struct {
Path string `json:"path"`
From int `json:"from"`
To int `json:"to"`
}
// WriteLinesParams defines the parameters for the writeLines tool.
type WriteLinesParams struct {
Path string `json:"path"`
Changes string `json:"changes"` // Each string is "line_num:content" or "line_num;"
}
// readLinesTool implements the readLines functionality as an MCP tool.
func readLinesTool(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[ReadLinesParams]) (*mcp.CallToolResultFor[any], error) {
var resultLines []string
file, err := os.Open(params.Arguments.Path)
if err != nil {
return nil, fmt.Errorf("could not open file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(customSplit)
lineNum := 1
for scanner.Scan() {
if lineNum >= params.Arguments.From && lineNum <= params.Arguments.To {
resultLines = append(resultLines, fmt.Sprintf("%d:%s", lineNum, scanner.Text()))
}
if lineNum > params.Arguments.To {
break
}
lineNum++
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error scanning file: %w", err)
}
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{&mcp.TextContent{Text: strings.Join(resultLines, "\n")}},
}, nil
}
// writeLinesTool implements the writeLines functionality as an MCP tool.
func writeLinesTool(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[WriteLinesParams]) (*mcp.CallToolResultFor[any], error) {
parsedChanges := make(map[int][]string)
// Split the single changes string into individual lines
changeLines := strings.Split(params.Arguments.Changes, "\n")
for _, change := range changeLines {
change = strings.TrimSpace(change) // Trim whitespace
if change == "" { // Ignore empty lines
continue
}
var lineNumStr, content string
isDelete := false
if strings.HasSuffix(change, ";") {
isDelete = true
lineNumStr = strings.TrimSuffix(change, ";")
} else {
parts := strings.SplitN(change, ":", 2)
if len(parts) != 2 {
// Error for invalid format
return nil, fmt.Errorf("invalid change format: %s. Expected 'line_num:content' or 'line_num;'", change)
}
lineNumStr, content = parts[0], parts[1]
}
lineNum, err := strconv.Atoi(lineNumStr)
if err != nil {
// Error for invalid line number
return nil, fmt.Errorf("invalid line number in change '%s': %w", change, err)
}
if isDelete {
parsedChanges[lineNum] = nil // Use nil to signify deletion
} else {
parsedChanges[lineNum] = append(parsedChanges[lineNum], content)
}
}
originalContent, err := os.ReadFile(params.Arguments.Path)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("could not read original file: %w", err)
}
newline := detectNewline(originalContent)
lines := strings.Split(string(originalContent), newline)
var resultLines []string
lineCounter := 1
for _, line := range lines {
if newContent, hasChange := parsedChanges[lineCounter]; hasChange {
if newContent != nil {
resultLines = append(resultLines, newContent...)
}
} else {
resultLines = append(resultLines, line)
}
lineCounter++
}
// Add new lines that are beyond the original file's length
var sortedKeys []int
for k := range parsedChanges {
sortedKeys = append(sortedKeys, k)
}
sort.Ints(sortedKeys)
for _, lineNum := range sortedKeys {
if lineNum > lineCounter-1 {
// Fill in blank lines if necessary
for i := lineCounter; i < lineNum; i++ {
resultLines = append(resultLines, "")
}
if newContent := parsedChanges[lineNum]; newContent != nil {
resultLines = append(resultLines, newContent...)
}
lineCounter = lineNum + 1
}
}
outputContent := strings.Join(resultLines, newline)
hasTrailingNewline := len(originalContent) > 0 && strings.HasSuffix(string(originalContent), newline)
if hasTrailingNewline && len(outputContent) > 0 && !strings.HasSuffix(outputContent, newline) {
outputContent += newline
}
if err := ioutil.WriteFile(params.Arguments.Path, []byte(outputContent), 0644); err != nil {
return nil, fmt.Errorf("could not write to file atomically: %w", err)
}
return &mcp.CallToolResultFor[any]{Content: []mcp.Content{}}, nil
}
// customSplit is a bufio.SplitFunc that splits on \n, \r\n, or \r.
func customSplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
// Look for a newline character
if i := bytes.IndexByte(data, '\n'); i >= 0 {
// We have a newline, check for \r\n
if i > 0 && data[i-1] == '\r' {
return i + 1, data[0 : i-1], nil // Return token without \r\n
}
return i + 1, data[0:i], nil // Return token without \n
}
// Look for a carriage return character (if no newline was found)
if i := bytes.IndexByte(data, '\r'); i >= 0 {
return i + 1, data[0:i], nil // Return token without \r
}
// If at EOF and no delimiter is found, return the remaining data as a token
if atEOF {
return len(data), data, nil
}
// Request more data
return 0, nil, nil
}
func detectNewline(content []byte) string {
if bytes.Contains(content, []byte("\r\n")) {
return "\r\n"
}
if bytes.Contains(content, []byte("\r")) {
return "\r"
}
return "\n"
}
func main() {
server := mcp.NewServer("linewise-mcp", "v1.0.0", nil)
server.AddTools(
mcp.NewServerTool("read_lines", "Read all or some lines from a file", readLinesTool, mcp.Input(
mcp.Property("path", mcp.Description("The path to the file to read.")),
mcp.Property("from", mcp.Description("The minimum line number to read (inclusive).")),
mcp.Property("to", mcp.Description("The maximum line number to read (inclusive).")),
)),
mcp.NewServerTool("write_lines", "Write some lines to a file", writeLinesTool, mcp.Input(
mcp.Property("path", mcp.Description("The path to the file to write.")),
mcp.Property("changes", mcp.Description("A string containing changes, each in 'line_num:content' or 'line_num;' format, separated by newlines. 'line_num:' empties the line while 'line_num;' removes the line. Empty lines are ignored. Multiple lines may have the same line number, in which cases the original line at that 'line_num' is replaced by all the new lines specified for that 'line_num', in the order they appear in the 'changes' string. For example, if a file contains 'Line 1\nLine 2\nLine 3' and changes is '2:Line A\n2:Line B', the result will be 'Line 1\nLine A\nLine B\nLine 3'.")),
)),
)
if err := server.Run(context.Background(), mcp.NewStdioTransport()); err != nil {
log.Fatal(err)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment