loov--lensm
273 行
7.0 KiB
Go
273 行
7.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"loov.dev/lensm/internal/comments"
|
|
)
|
|
|
|
type AppServer struct {
|
|
httpServer *http.Server
|
|
url string
|
|
load LoadFile
|
|
commentsPath string
|
|
|
|
// mu guards the fields below.
|
|
mu sync.Mutex
|
|
session *Session
|
|
loadError error
|
|
generation uint64
|
|
// active counts in-flight requests using session; a replaced
|
|
// session is closed only once they have finished.
|
|
active *sync.WaitGroup
|
|
}
|
|
|
|
func StartAppServer(load LoadFile, commentsPath string) (*AppServer, error) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:7077")
|
|
if err != nil {
|
|
listener, err = net.Listen("tcp", "127.0.0.1:0")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
server := &AppServer{
|
|
url: "http://" + listener.Addr().String() + "/mcp",
|
|
load: load,
|
|
commentsPath: commentsPath,
|
|
active: &sync.WaitGroup{},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", server.handleHTTP)
|
|
httpServer := &http.Server{
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: time.Minute,
|
|
WriteTimeout: 5 * time.Minute,
|
|
IdleTimeout: 2 * time.Minute,
|
|
}
|
|
server.httpServer = httpServer
|
|
|
|
go func() {
|
|
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
fmt.Fprintf(os.Stderr, "lensm MCP server stopped: %v\n", err)
|
|
}
|
|
}()
|
|
return server, nil
|
|
}
|
|
|
|
func (server *AppServer) URL() string {
|
|
if server == nil {
|
|
return ""
|
|
}
|
|
return server.url
|
|
}
|
|
|
|
func (server *AppServer) SetPath(path string, store *comments.Store) {
|
|
if server == nil {
|
|
return
|
|
}
|
|
path = comments.CleanPath(path)
|
|
|
|
server.mu.Lock()
|
|
server.generation++
|
|
generation := server.generation
|
|
commentsPath := server.commentsPath
|
|
old, oldActive := server.session, server.active
|
|
server.session = nil
|
|
server.loadError = nil
|
|
server.active = &sync.WaitGroup{}
|
|
server.mu.Unlock()
|
|
// SetPath runs on the UI event loop: don't block on in-flight requests.
|
|
closeSessionWhenIdle(old, oldActive)
|
|
|
|
if path == "" {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
session, err := NewSessionWithComments(server.load, path, commentsPath, store)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "unable to load MCP session for %q: %v\n", path, err)
|
|
server.replaceSession(generation, nil, err)
|
|
return
|
|
}
|
|
server.replaceSession(generation, session, nil)
|
|
}()
|
|
}
|
|
|
|
func (server *AppServer) Close() error {
|
|
if server == nil {
|
|
return nil
|
|
}
|
|
server.mu.Lock()
|
|
server.generation++
|
|
old, oldActive := server.session, server.active
|
|
server.session = nil
|
|
server.loadError = nil
|
|
server.active = &sync.WaitGroup{}
|
|
server.mu.Unlock()
|
|
if old != nil {
|
|
if oldActive != nil {
|
|
oldActive.Wait()
|
|
}
|
|
_ = old.Close()
|
|
}
|
|
if server.httpServer == nil {
|
|
return nil
|
|
}
|
|
return server.httpServer.Close()
|
|
}
|
|
|
|
func (server *AppServer) replaceSession(generation uint64, session *Session, loadErr error) {
|
|
server.mu.Lock()
|
|
if generation != server.generation {
|
|
server.mu.Unlock()
|
|
if session != nil {
|
|
_ = session.Close()
|
|
}
|
|
return
|
|
}
|
|
old, oldActive := server.session, server.active
|
|
server.session = session
|
|
server.loadError = loadErr
|
|
server.active = &sync.WaitGroup{}
|
|
server.mu.Unlock()
|
|
closeSessionWhenIdle(old, oldActive)
|
|
}
|
|
|
|
// closeSessionWhenIdle closes session once in-flight requests holding it
|
|
// have finished, without blocking the caller.
|
|
func closeSessionWhenIdle(session *Session, active *sync.WaitGroup) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
go func() {
|
|
if active != nil {
|
|
active.Wait()
|
|
}
|
|
_ = session.Close()
|
|
}()
|
|
}
|
|
|
|
func (server *AppServer) handleHTTP(w http.ResponseWriter, req *http.Request) {
|
|
if req.URL.Path != "/" && req.URL.Path != "/mcp" {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
// The MCP streamable-HTTP spec requires validating Origin (and, for a
|
|
// loopback server, Host) to block DNS-rebinding pages from driving the
|
|
// server; the CORS headers below are advisory only.
|
|
if !isLoopbackHost(req.Host) {
|
|
http.Error(w, "forbidden host", http.StatusForbidden)
|
|
return
|
|
}
|
|
if origin := req.Header.Get("Origin"); origin != "" && !isLoopbackOrigin(origin) {
|
|
http.Error(w, "forbidden origin", http.StatusForbidden)
|
|
return
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Origin", "http://localhost")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id")
|
|
|
|
switch req.Method {
|
|
case http.MethodOptions:
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"name": "lensm",
|
|
"mcp": server.url,
|
|
})
|
|
case http.MethodPost:
|
|
server.handleHTTPPost(w, req)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (server *AppServer) handleHTTPPost(w http.ResponseWriter, req *http.Request) {
|
|
defer req.Body.Close()
|
|
data, err := io.ReadAll(io.LimitReader(req.Body, 64*1024*1024))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
var msg rpcMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(w).Encode(rpcMessage{
|
|
JSONRPC: "2.0",
|
|
// JSON-RPC 2.0 requires "id": null when the request id is
|
|
// unknowable, otherwise strict clients cannot correlate it.
|
|
ID: json.RawMessage("null"),
|
|
Error: &rpcError{Code: -32700, Message: "parse error"},
|
|
})
|
|
return
|
|
}
|
|
|
|
response, ok := server.handleHTTPMessage(msg)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (server *AppServer) handleHTTPMessage(msg rpcMessage) (rpcMessage, bool) {
|
|
session, loadErr, release := server.acquireSession()
|
|
defer release()
|
|
return (&mcpServer{session: session, loadErr: loadErr}).handle(msg)
|
|
}
|
|
|
|
// acquireSession snapshots the current session and keeps it open until
|
|
// release is called. The server lock is not held while a request is
|
|
// handled, so concurrent requests and SetPath never wait on a handler.
|
|
func (server *AppServer) acquireSession() (session *Session, loadErr error, release func()) {
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if server.active == nil {
|
|
server.active = &sync.WaitGroup{}
|
|
}
|
|
active := server.active
|
|
active.Add(1)
|
|
return server.session, server.loadError, active.Done
|
|
}
|
|
|
|
// isLoopbackHost reports whether a Host header (optionally host:port)
|
|
// refers to this machine.
|
|
func isLoopbackHost(hostport string) bool {
|
|
host := hostport
|
|
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
|
host = h
|
|
}
|
|
host = strings.Trim(host, "[]")
|
|
if strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// isLoopbackOrigin reports whether an Origin header value refers to a
|
|
// page served from this machine.
|
|
func isLoopbackOrigin(origin string) bool {
|
|
u, err := url.Parse(origin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return isLoopbackHost(u.Host)
|
|
}
|