a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
362 lines
13 KiB
Go
362 lines
13 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/lspuri"
|
|
"github.com/zzet/gortex/internal/semantic/lsp"
|
|
)
|
|
|
|
// diagnosticsBroadcaster forwards `textDocument/publishDiagnostics`
|
|
// payloads from every router-managed LSP provider to MCP clients as
|
|
// `notifications/diagnostics`. Subscribed clients receive push events
|
|
// instead of polling `get_diagnostics`.
|
|
//
|
|
// Subscription is opt-in per session via `subscribe_diagnostics`. Each
|
|
// subscriber carries a small filter set (minimum severity, path
|
|
// prefix) so different sessions can subscribe to disjoint slices of
|
|
// the workspace's diagnostic stream. Notifications are delivered
|
|
// per-session via `SendNotificationToSpecificClient` — sessions that
|
|
// did not subscribe never see any payload.
|
|
//
|
|
// Delta-only: each (file, content-hash) pair fires the broadcast at
|
|
// most once. Repeated identical publishes (which some servers emit on
|
|
// every save even when nothing changed) are suppressed at the
|
|
// broadcaster.
|
|
//
|
|
// Initial-state replay: a fresh subscribe call walks the snapshot of
|
|
// every alive provider and sends the current diagnostic state for
|
|
// every file matching the subscriber's filter, tagged
|
|
// `initial_replay: true`. After the replay the subscriber receives
|
|
// only deltas.
|
|
type diagnosticsBroadcaster struct {
|
|
server specificNotificationSender
|
|
logger *zap.Logger
|
|
|
|
// snapFn returns the current diagnostic state across every alive
|
|
// provider. Called at subscribe time for initial-state replay.
|
|
// Nil disables replay (used by tests that don't wire the router).
|
|
snapFn func() []lsp.DiagnosticsEntry
|
|
|
|
mu sync.RWMutex
|
|
subscribers map[string]*diagnosticsSubscriber // session ID → filter
|
|
lastHash map[string]string // absPath → sha256(diagnostics) for delta filter
|
|
}
|
|
|
|
// diagnosticsSubscriber records a session's filter knobs.
|
|
type diagnosticsSubscriber struct {
|
|
sessionID string
|
|
minSeverity int // LSP severities: 1=error, 2=warning, 3=info, 4=hint. 0 = no filter.
|
|
pathPrefix string // absolute-path prefix; empty = all files.
|
|
}
|
|
|
|
// subscribeOptions are the per-subscribe filter inputs surfaced by
|
|
// `subscribe_diagnostics`.
|
|
type subscribeOptions struct {
|
|
MinSeverity int
|
|
PathPrefix string
|
|
}
|
|
|
|
// specificNotificationSender is the slice of *server.MCPServer the
|
|
// broadcaster needs to deliver per-session push notifications.
|
|
// Defined here so tests can inject a recorder without spinning up a
|
|
// full MCP server. Distinct from progress.go's `notificationSender`
|
|
// (which routes SendNotificationToClient via context-extracted
|
|
// session) — diagnostics need to address arbitrary subscribed
|
|
// sessions rather than the in-flight tool-request session.
|
|
type specificNotificationSender interface {
|
|
SendNotificationToSpecificClient(sessionID string, method string, params map[string]any) error
|
|
}
|
|
|
|
func newDiagnosticsBroadcaster(srv specificNotificationSender, snapFn func() []lsp.DiagnosticsEntry, logger *zap.Logger) *diagnosticsBroadcaster {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &diagnosticsBroadcaster{
|
|
server: srv,
|
|
logger: logger,
|
|
snapFn: snapFn,
|
|
subscribers: make(map[string]*diagnosticsSubscriber),
|
|
lastHash: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// publish is the router-level hook target. Builds the MCP notification
|
|
// payload, applies the delta filter, and delivers per-subscriber via
|
|
// `SendNotificationToSpecificClient`. Non-blocking: each delivery
|
|
// returns immediately, the SDK drops to its error hook when a
|
|
// session's notification channel is full.
|
|
func (b *diagnosticsBroadcaster) publish(specName, absPath string, diags []lsp.Diagnostic) {
|
|
if b == nil || b.server == nil {
|
|
return
|
|
}
|
|
|
|
hash := hashDiagnostics(diags)
|
|
|
|
b.mu.Lock()
|
|
prev, had := b.lastHash[absPath]
|
|
if len(diags) == 0 {
|
|
// A cleared file. Suppress unless it was previously non-empty:
|
|
// a file that never had (or already dropped) diagnostics must
|
|
// not emit a spurious clear. Drop the key on the clear so
|
|
// lastHash only ever holds live non-empty fingerprints — that,
|
|
// not an "empty" entry accumulated per file the server ever
|
|
// touched, is what keeps the map bounded.
|
|
if !had {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
delete(b.lastHash, absPath)
|
|
} else {
|
|
if prev == hash {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
b.lastHash[absPath] = hash
|
|
}
|
|
subs := make([]*diagnosticsSubscriber, 0, len(b.subscribers))
|
|
for _, s := range b.subscribers {
|
|
subs = append(subs, s)
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
if len(subs) == 0 {
|
|
// Hash state already updated above — late subscribers won't see
|
|
// a stale replay of this payload during their initial snapshot.
|
|
return
|
|
}
|
|
|
|
for _, sub := range subs {
|
|
if !pathMatchesScope(absPath, sub) {
|
|
continue
|
|
}
|
|
filtered := filterDiagnosticsBySeverity(diags, sub.minSeverity)
|
|
params := map[string]any{
|
|
"uri": pathToFileURI(absPath),
|
|
"path": absPath,
|
|
"server": specName,
|
|
"diagnostics": diagsToWire(filtered),
|
|
}
|
|
if err := b.server.SendNotificationToSpecificClient(sub.sessionID, "notifications/diagnostics", params); err != nil {
|
|
// Common cases: session disconnected mid-flight, or its
|
|
// notification channel is full. Both are recoverable —
|
|
// the MCP SDK has its own error-hook for the latter.
|
|
b.logger.Debug("send diagnostics notification failed",
|
|
zap.String("session", sub.sessionID),
|
|
zap.String("path", absPath),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// subscribe records sessionID with the given filter knobs and
|
|
// immediately replays the current diagnostic state matching the
|
|
// subscriber's scope. Returns the number of replayed file payloads.
|
|
//
|
|
// Idempotent in the sense that resubscribing with new options
|
|
// overwrites the previous filter; the replay re-fires either way so
|
|
// the new filter produces a fresh view of the current state.
|
|
func (b *diagnosticsBroadcaster) subscribe(sessionID string, opts subscribeOptions) int {
|
|
if sessionID == "" {
|
|
return 0
|
|
}
|
|
sub := &diagnosticsSubscriber{
|
|
sessionID: sessionID,
|
|
minSeverity: opts.MinSeverity,
|
|
pathPrefix: opts.PathPrefix,
|
|
}
|
|
b.mu.Lock()
|
|
b.subscribers[sessionID] = sub
|
|
snapFn := b.snapFn
|
|
b.mu.Unlock()
|
|
|
|
if snapFn == nil {
|
|
return 0
|
|
}
|
|
snap := snapFn()
|
|
sent := 0
|
|
for _, e := range snap {
|
|
if !pathMatchesScope(e.AbsPath, sub) {
|
|
continue
|
|
}
|
|
filtered := filterDiagnosticsBySeverity(e.Diagnostics, sub.minSeverity)
|
|
params := map[string]any{
|
|
"uri": pathToFileURI(e.AbsPath),
|
|
"path": e.AbsPath,
|
|
"server": e.SpecName,
|
|
"diagnostics": diagsToWire(filtered),
|
|
"initial_replay": true,
|
|
}
|
|
if err := b.server.SendNotificationToSpecificClient(sub.sessionID, "notifications/diagnostics", params); err != nil {
|
|
b.logger.Debug("send initial-replay notification failed",
|
|
zap.String("session", sub.sessionID),
|
|
zap.String("path", e.AbsPath),
|
|
zap.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
sent++
|
|
}
|
|
return sent
|
|
}
|
|
|
|
// unsubscribe removes sessionID from the opt-in set. Idempotent. Safe
|
|
// to call from session-lifecycle hooks even when the session never
|
|
// subscribed.
|
|
func (b *diagnosticsBroadcaster) unsubscribe(sessionID string) {
|
|
if sessionID == "" {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
delete(b.subscribers, sessionID)
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
// subscriberCount returns the number of opted-in sessions. Used by
|
|
// status / debug surfaces.
|
|
func (b *diagnosticsBroadcaster) subscriberCount() int {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
return len(b.subscribers)
|
|
}
|
|
|
|
// pathMatchesScope reports whether absPath falls inside the
|
|
// subscriber's configured path prefix. Empty prefix matches everything.
|
|
func pathMatchesScope(absPath string, sub *diagnosticsSubscriber) bool {
|
|
if sub == nil || sub.pathPrefix == "" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(absPath, sub.pathPrefix)
|
|
}
|
|
|
|
// filterDiagnosticsBySeverity drops every entry with severity numerically
|
|
// greater than minSeverity (LSP convention: lower number = higher
|
|
// severity, 1=error, 4=hint). minSeverity ≤ 0 disables the filter.
|
|
//
|
|
// Returns a fresh slice — never aliases the input.
|
|
func filterDiagnosticsBySeverity(diags []lsp.Diagnostic, minSeverity int) []lsp.Diagnostic {
|
|
if minSeverity <= 0 {
|
|
out := make([]lsp.Diagnostic, len(diags))
|
|
copy(out, diags)
|
|
return out
|
|
}
|
|
out := make([]lsp.Diagnostic, 0, len(diags))
|
|
for _, d := range diags {
|
|
if d.Severity == 0 || d.Severity <= minSeverity {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hashDiagnostics derives a stable fingerprint for a diagnostic list
|
|
// so the delta filter can suppress identical re-publishes.
|
|
func hashDiagnostics(diags []lsp.Diagnostic) string {
|
|
if len(diags) == 0 {
|
|
return "empty"
|
|
}
|
|
b, err := json.Marshal(diags)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// pathToFileURI converts an absolute filesystem path to a file:// URI.
|
|
// Centralised (via internal/lspuri) so push payloads match the URI shape
|
|
// MCP clients expect — Windows-correct (drive letter + separators).
|
|
func pathToFileURI(absPath string) string {
|
|
return lspuri.PathToURI(absPath)
|
|
}
|
|
|
|
// registerDiagnosticsTools wires the subscribe/unsubscribe MCP tools.
|
|
// `subscribe_diagnostics` opts the calling session into push
|
|
// notifications; `unsubscribe_diagnostics` opts it back out.
|
|
func (s *Server) registerDiagnosticsTools() {
|
|
s.addTool(
|
|
mcp.NewTool("subscribe_diagnostics",
|
|
mcp.WithDescription("Opt the current MCP session into `notifications/diagnostics` push events. Once subscribed, every LSP `textDocument/publishDiagnostics` matching your filter is forwarded to your session as an MCP notification with `{uri, path, server, diagnostics}` payload. The current diagnostic state of every matching file is replayed immediately as `initial_replay: true` so you don't have to wait for the next edit. Optional `min_severity` (1=error, 2=warning, 3=info, 4=hint; default 0=all) and `path_prefix` (absolute path prefix; default empty=all files) restrict which payloads reach this session. Resubscribing overwrites the previous filter and re-replays the current state. Pair with `unsubscribe_diagnostics` to opt back out."),
|
|
mcp.WithNumber("min_severity", mcp.Description("Drop diagnostics whose LSP severity number exceeds this value. 1=error, 2=warning, 3=info, 4=hint. 0 (default) keeps everything.")),
|
|
mcp.WithString("path_prefix", mcp.Description("Absolute-path prefix; only diagnostics for files under this prefix are forwarded. Empty (default) keeps everything.")),
|
|
),
|
|
s.handleSubscribeDiagnostics,
|
|
)
|
|
s.addTool(
|
|
mcp.NewTool("unsubscribe_diagnostics",
|
|
mcp.WithDescription("Opt the current MCP session out of `notifications/diagnostics` push events. Idempotent."),
|
|
),
|
|
s.handleUnsubscribeDiagnostics,
|
|
)
|
|
}
|
|
|
|
func (s *Server) handleSubscribeDiagnostics(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
if s.diagBroadcaster == nil {
|
|
return mcp.NewToolResultError("diagnostics broadcaster is not configured (no LSP router)"), nil
|
|
}
|
|
id := SessionIDFromContext(ctx)
|
|
if id == "" {
|
|
// Embedded mode — single client. Use a sentinel so the
|
|
// broadcaster has at least one subscriber.
|
|
id = "embedded"
|
|
}
|
|
opts := subscribeOptions{
|
|
MinSeverity: int(req.GetFloat("min_severity", 0)),
|
|
PathPrefix: req.GetString("path_prefix", ""),
|
|
}
|
|
replayed := s.diagBroadcaster.subscribe(id, opts)
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"subscribed": true,
|
|
"session_id": id,
|
|
"subscribers": s.diagBroadcaster.subscriberCount(),
|
|
"min_severity": opts.MinSeverity,
|
|
"path_prefix": opts.PathPrefix,
|
|
"replayed": replayed,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUnsubscribeDiagnostics(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
if s.diagBroadcaster == nil {
|
|
return mcp.NewToolResultError("diagnostics broadcaster is not configured"), nil
|
|
}
|
|
id := SessionIDFromContext(ctx)
|
|
if id == "" {
|
|
id = "embedded"
|
|
}
|
|
s.diagBroadcaster.unsubscribe(id)
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"subscribed": false,
|
|
"session_id": id,
|
|
"subscribers": s.diagBroadcaster.subscriberCount(),
|
|
})
|
|
}
|
|
|
|
// SetLSPDiagnosticsBroadcasting attaches a forwarding hook to the
|
|
// configured LSP router so every router-managed publishDiagnostics
|
|
// turns into a `notifications/diagnostics` delivery (subject to the
|
|
// per-session opt-in set + filters). Safe to call before or after
|
|
// tools are registered.
|
|
//
|
|
// No-ops cleanly when no LSP router is wired.
|
|
func (s *Server) SetLSPDiagnosticsBroadcasting() {
|
|
r := s.lspRouter()
|
|
if r == nil {
|
|
return
|
|
}
|
|
if s.diagBroadcaster == nil {
|
|
s.diagBroadcaster = newDiagnosticsBroadcaster(s.mcpServer, r.DiagnosticsSnapshot, s.logger)
|
|
}
|
|
r.SetDiagnosticsHook(func(specName, absPath string, diags []lsp.Diagnostic) {
|
|
s.diagBroadcaster.publish(specName, absPath, diags)
|
|
})
|
|
}
|