Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
package main

import (
	"bufio"
	"html/template"
	"log"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

var projectRoot string
var devopsDir string

var baseTmpl = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>MCP Server</title>
    <style>
        form { margin: 2px 0; }
        button, input { margin: 0 2px 0 0; }
    </style>
</head>
<body>
    <p>Hello from MCP server</p>
    <div>
        <a href="/dev/files/">List Files</a> |
        <a href="/dev/commands/">Just Commands</a> |
        <a href="/dev/repo/">Repo</a> |
        <a href="/dev/logs/">Logs</a>
    </div>
    <hr>
    {{if .SubNav}}<div>{{.SubNav}}</div>{{end}}
    <div>{{.Content}}</div>
</body>
</html>`))

type PageData struct {
	SubNav  template.HTML
	Content template.HTML
}

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8082"
	}

	cwd, _ := os.Getwd()
	devopsDir = filepath.Dir(cwd)
	projectRoot = filepath.Dir(devopsDir)
	log.Printf("Project root: %s", projectRoot)

	http.HandleFunc("/", handleIndex)
	http.HandleFunc("/dev/files/", handleFiles)
	http.HandleFunc("/dev/commands/", handleCommands)
	http.HandleFunc("/dev/repo/", handleRepo)
	http.HandleFunc("/dev/logs/", handleLogs)
	http.HandleFunc("/health", handleHealth)

	log.Printf("MCP server starting on port %s", port)
	if err := http.ListenAndServe(":"+port, nil); err != nil {
		log.Fatal(err)
	}
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}
	render(w, PageData{})
}

func handleFiles(w http.ResponseWriter, r *http.Request) {
	path := strings.TrimPrefix(r.URL.Path, "/dev/files/")

	// Check for file view
	if r.URL.Query().Get("file") != "" {
		file := r.URL.Query().Get("file")
		render(w, PageData{Content: template.HTML(renderFile(file))})
		return
	}

	render(w, PageData{Content: template.HTML(renderFiles(path))})
}

func handleCommands(w http.ResponseWriter, r *http.Request) {
	action := r.URL.Query().Get("action")

	if action == "run" {
		cmd := r.URL.Query().Get("cmd")
		args := r.URL.Query().Get("args")
		content := renderRunCommand(cmd, args)
		render(w, PageData{Content: template.HTML(content)})
		return
	}

	render(w, PageData{Content: template.HTML(renderJustCommands())})
}

func handleRepo(w http.ResponseWriter, r *http.Request) {
	action := r.URL.Query().Get("action")

	subNav := `<a href="/dev/repo/?action=fetch">Fetch</a> | <a href="/dev/repo/?action=changes">Changes</a>`

	var content string
	var message string

	switch action {
	case "fetch":
		output, err := runGit("fetch", "--all", "-v")
		if err != nil {
			message = "Fetch failed: " + output
		} else {
			if output == "" {
				message = "Fetch complete (no updates)"
			} else {
				message = "Fetch complete:\n" + output
			}
		}
		subNav += `<br><br>
			<form action="/dev/repo/" method="get" style="display:inline">
				<input type="hidden" name="action" value="newbranch">
				<input type="text" name="name" placeholder="new branch name">
				<button type="submit">Create Branch</button>
			</form>
			|
			<form action="/dev/repo/" method="get" style="display:inline">
				<input type="hidden" name="action" value="switch">
				<select name="name">` + renderBranchOptions() + `</select>
				<button type="submit">Switch Branch</button>
			</form>`
		content = renderRepoStatus()
		if message != "" {
			content = message + "\n\n" + content
		}
	case "newbranch":
		name := r.URL.Query().Get("name")
		if name == "" {
			content = "Branch name required"
		} else {
			output, err := runGit("checkout", "-b", name)
			if err != nil {
				content = "Failed to create branch: " + output
			} else {
				content = "Created and switched to branch: " + name + "\n" + output
			}
		}
	case "switch":
		name := r.URL.Query().Get("name")
		if name == "" {
			content = "Branch name required"
		} else {
			var output string
			var err error
			if strings.Contains(name, "/") {
				// Remote branch - create local tracking branch
				output, err = runGit("checkout", "--track", name)
			} else {
				// Local branch - just switch
				output, err = runGit("checkout", name)
			}
			if err != nil {
				content = "Failed to switch branch: " + output
			} else {
				content = "Switched to branch: " + name + "\n" + output
			}
		}
	case "changes":
		subNav += `<br><br><form action="/dev/repo/" method="get" style="display:inline">
			<input type="hidden" name="action" value="commit">
			<input type="text" name="msg" placeholder="commit message (optional)">
			<button type="submit">Commit &amp; Push</button>
		</form>`
		content = renderChanges()
	case "commit":
		msg := r.URL.Query().Get("msg")
		content = renderRepoCommitPush(msg)
	default:
		content = renderRepoStatus()
	}

	render(w, PageData{SubNav: template.HTML(subNav), Content: template.HTML(content)})
}

func handleLogs(w http.ResponseWriter, r *http.Request) {
	render(w, PageData{Content: template.HTML(renderLogs())})
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(`{"status": "ok"}`))
}

func render(w http.ResponseWriter, data PageData) {
	w.Header().Set("Content-Type", "text/html")
	baseTmpl.Execute(w, data)
}

func renderFiles(path string) string {
	if strings.Contains(filepath.Clean(path), "..") {
		return "invalid path"
	}

	dirPath := filepath.Join(projectRoot, path)
	entries, err := os.ReadDir(dirPath)
	if err != nil {
		return "error: " + err.Error()
	}

	var lines []string
	if path != "" {
		parent := filepath.Dir(path)
		if parent == "." {
			parent = ""
		}
		lines = append(lines, `<a href="/dev/files/`+parent+`">..</a>`)
	}

	for _, entry := range entries {
		name := entry.Name()
		if entry.IsDir() {
			fullPath := filepath.Join(path, name)
			lines = append(lines, `<a href="/dev/files/`+fullPath+`/">`+name+`/</a>`)
		} else {
			fullPath := filepath.Join(path, name)
			lines = append(lines, `<a href="/dev/files/?file=`+fullPath+`">`+name+`</a>`)
		}
	}

	return strings.Join(lines, "\n")
}

func renderFile(name string) string {
	if strings.Contains(filepath.Clean(name), "..") {
		return "invalid path"
	}

	filePath := filepath.Join(projectRoot, name)
	content, err := os.ReadFile(filePath)
	if err != nil {
		return "error: " + err.Error()
	}

	escaped := escapeHTML(string(content))

	parent := filepath.Dir(name)
	if parent == "." {
		parent = ""
	}

	return `<a href="/dev/files/` + parent + `">← back</a> | <button onclick="navigator.clipboard.writeText(document.getElementById('filecontent').textContent)">Copy</button>

<pre id="filecontent">` + escaped + `</pre>`
}

func renderJustCommands() string {
	justfilePath := filepath.Join(devopsDir, "justfile")

	file, err := os.Open(justfilePath)
	if err != nil {
		return "error: " + err.Error()
	}
	defer file.Close()

	var lines []string
	var lastComment string

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()

		if strings.HasPrefix(line, "#") {
			lastComment = strings.TrimPrefix(line, "# ")
		} else if len(line) > 0 && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, "@") && strings.Contains(line, ":") {
			parts := strings.SplitN(line, ":", 2)
			nameParts := strings.TrimSpace(parts[0])
			tokens := strings.Fields(nameParts)
			if len(tokens) == 0 {
				continue
			}
			name := tokens[0]
			params := tokens[1:]

			if name != "" && !strings.HasPrefix(name, "set") && name != "default" {
				form := `<form action="/dev/commands/" method="get">
					<input type="hidden" name="action" value="run">
					<input type="hidden" name="cmd" value="` + name + `">
					<button type="submit">` + name + `</button>`

				if len(params) > 0 {
					var paramNames []string
					for _, p := range params {
						// Strip default values like param="default"
						pName := strings.Split(p, "=")[0]
						paramNames = append(paramNames, pName)
					}
					form += ` <input type="text" name="args" placeholder="` + strings.Join(paramNames, " ") + `" size="40">`
				}

				if lastComment != "" {
					form += ` <span>` + lastComment + `</span>`
				}
				form += `</form>`
				lines = append(lines, form)
			}
			lastComment = ""
		} else if line == "" {
			lastComment = ""
		}
	}

	return strings.Join(lines, "\n")
}

func renderRunCommand(cmd, args string) string {
	if cmd == "" {
		return "No command specified"
	}

	cmdArgs := []string{cmd}
	if args != "" {
		cmdArgs = append(cmdArgs, strings.Fields(args)...)
	}

	execCmd := exec.Command("just", cmdArgs...)
	execCmd.Dir = devopsDir
	output, err := execCmd.CombinedOutput()

	result := "Running: just " + strings.Join(cmdArgs, " ") + "\n\n"
	if err != nil {
		result += "Error: " + err.Error() + "\n\n"
	}
	result += escapeHTML(string(output))

	return `<a href="/dev/commands/">← back</a>
<pre>` + result + `</pre>`
}

func renderRepoStatus() string {
	var lines []string

	branch, err := runGit("rev-parse", "--abbrev-ref", "HEAD")
	if err != nil {
		return "error: " + err.Error()
	}
	lines = append(lines, "Branch: "+strings.TrimSpace(branch))
	lines = append(lines, "")

	status, _ := runGit("status", "--short")
	if status == "" {
		lines = append(lines, "Status: clean")
	} else {
		lines = append(lines, "Status:")
		lines = append(lines, status)
	}
	lines = append(lines, "")

	commits, _ := runGit("log", "--oneline", "-10")
	lines = append(lines, "Recent commits:")
	lines = append(lines, commits)
	lines = append(lines, "")

	branches, _ := runGit("branch", "-a")
	lines = append(lines, "Branches:")
	lines = append(lines, branches)

	return "<pre>" + strings.Join(lines, "\n") + "</pre>"
}

func renderChanges() string {
	status, err := runGit("status")
	if err != nil {
		return "error: " + err.Error()
	}
	return "<pre>" + escapeHTML(status) + "</pre>"
}

func renderRepoCommitPush(msg string) string {
	var lines []string

	branch, _ := runGit("rev-parse", "--abbrev-ref", "HEAD")
	branch = strings.TrimSpace(branch)

	if branch == "master" || branch == "main" {
		return "Cannot push to " + branch + " directly. Create a feature branch first."
	}

	status, _ := runGit("status", "--short")
	if status == "" {
		return "Nothing to commit, working tree clean"
	}

	output, err := runGit("add", ".")
	if err != nil {
		return "Failed to stage changes: " + output
	}
	lines = append(lines, "Staged all changes")

	commitMsg := "wip"
	if msg != "" {
		commitMsg = msg
	}

	output, err = runGit("commit", "-m", commitMsg)
	if err != nil {
		return "Failed to commit: " + output
	}
	lines = append(lines, "Committed: "+commitMsg)

	output, err = runGit("push", "-u", "origin", branch)
	if err != nil {
		lines = append(lines, "Failed to push: "+output)
	} else {
		lines = append(lines, "Pushed to "+branch)
		if output != "" {
			lines = append(lines, output)
		}
	}

	return "<pre>" + strings.Join(lines, "\n") + "</pre>"
}

func renderLogs() string {
	return "<pre>Logs are output to terminal.\n\nCheck the terminal where 'just dev-mcp' is running.</pre>"
}

func renderBranchOptions() string {
	var options []string

	// Get current branch
	current, _ := runGit("rev-parse", "--abbrev-ref", "HEAD")
	current = strings.TrimSpace(current)

	// Local branches
	localOutput, _ := runGit("branch", "--format=%(refname:short)")
	localBranches := strings.Split(strings.TrimSpace(localOutput), "\n")

	if len(localBranches) > 0 && localBranches[0] != "" {
		options = append(options, `<optgroup label="Local">`)
		for _, b := range localBranches {
			b = strings.TrimSpace(b)
			if b == "" {
				continue
			}
			selected := ""
			if b == current {
				selected = " selected"
			}
			options = append(options, `<option value="`+b+`"`+selected+`>`+b+`</option>`)
		}
		options = append(options, `</optgroup>`)
	}

	// Remote branches
	remoteOutput, _ := runGit("branch", "-r", "--format=%(refname:short)")
	remoteBranches := strings.Split(strings.TrimSpace(remoteOutput), "\n")

	if len(remoteBranches) > 0 && remoteBranches[0] != "" {
		options = append(options, `<optgroup label="Remote">`)
		for _, b := range remoteBranches {
			b = strings.TrimSpace(b)
			if b == "" || strings.Contains(b, "HEAD") {
				continue
			}
			options = append(options, `<option value="`+b+`">`+b+`</option>`)
		}
		options = append(options, `</optgroup>`)
	}

	return strings.Join(options, "\n")
}

func runGit(args ...string) (string, error) {
	cmd := exec.Command("git", args...)
	cmd.Dir = projectRoot
	out, err := cmd.CombinedOutput()
	return string(out), err
}

func escapeHTML(s string) string {
	s = strings.ReplaceAll(s, "&", "&amp;")
	s = strings.ReplaceAll(s, "<", "&lt;")
	s = strings.ReplaceAll(s, ">", "&gt;")
	return s
}