Created
July 8, 2025 12:10
-
-
Save lifthrasiir/32d65c2acb69b3d0eeab92e84c96e07b to your computer and use it in GitHub Desktop.
Linewise MCP (vibe-coded with Gemini)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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