| 1 | package plugin |
| 2 | |
| 3 | import ( |
| 4 | "sort" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // CapabilityViewLayer names for the four-layer capability matrix. |
| 9 | const ( |
| 10 | CapabilityIDProtocol = "protocol" |
| 11 | CapabilityIDCore = "core" |
| 12 | CapabilityIDInteractive = "interactive" |
| 13 | CapabilityIDApps = "apps" |
| 14 | |
| 15 | LayerProtocol = "Protocol Connection" |
| 16 | LayerCore = "Core Host" |
| 17 | LayerInteractive = "Interactive Host" |
| 18 | LayerApps = "Apps Host" |
| 19 | |
| 20 | CapabilityStateSupported = "supported" |
| 21 | CapabilityStateNegotiated = "negotiated" |
| 22 | CapabilityStateDegraded = "degraded" |
| 23 | CapabilityStateUnavailable = "unavailable" |
| 24 | ) |
| 25 | |
| 26 | // CapabilityView is one row of the public MCP capability matrix: what the host |
| 27 | // declares, whether the live sessions turned it into a negotiated capability, |
| 28 | // and a human-readable detail. Read-only diagnostics — there is no editable |
| 29 | // switch; capabilities follow the frontend's profile. |
| 30 | type CapabilityView struct { |
| 31 | ID string `json:"id"` |
| 32 | Layer string `json:"layer"` |
| 33 | State string `json:"state"` |
| 34 | Negotiated bool `json:"negotiated"` |
| 35 | Detail string `json:"detail"` |
| 36 | } |
| 37 | |
| 38 | // CapabilityViews computes the host's four-layer capability matrix from the |
| 39 | // profile and the live sessions' negotiation results. |
| 40 | func (h *Host) CapabilityViews() []CapabilityView { |
| 41 | profile := h.Profile() |
| 42 | profileCaps := profile.Capabilities() |
| 43 | |
| 44 | clients := h.snapshotClients() |
| 45 | anyConnected := false |
| 46 | newestProtocol := "" |
| 47 | elicitationLive := false |
| 48 | appsLive := false |
| 49 | for _, c := range clients { |
| 50 | if c.closed.Load() { |
| 51 | continue |
| 52 | } |
| 53 | anyConnected = true |
| 54 | if c.protocolVersion > newestProtocol { |
| 55 | newestProtocol = c.protocolVersion |
| 56 | } |
| 57 | if profileCaps.ElicitationForms && c.elicitationUsable() { |
| 58 | elicitationLive = true |
| 59 | } |
| 60 | if profileCaps.AppsUI && c.capabilities.appsUI() { |
| 61 | appsLive = true |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | views := make([]CapabilityView, 0, 4) |
| 66 | protocolDetail := "JSON-RPC protocol revision negotiated per server" |
| 67 | protocolState := CapabilityStateSupported |
| 68 | if anyConnected { |
| 69 | protocolState = CapabilityStateNegotiated |
| 70 | if newestProtocol != "" { |
| 71 | protocolDetail = "Newest negotiated revision: " + newestProtocol |
| 72 | } |
| 73 | } else { |
| 74 | protocolDetail = "No server connected yet; " + protocolDetail |
| 75 | } |
| 76 | views = append(views, CapabilityView{ |
| 77 | ID: CapabilityIDProtocol, Layer: LayerProtocol, |
| 78 | State: protocolState, Negotiated: anyConnected, Detail: protocolDetail, |
| 79 | }) |
| 80 | |
| 81 | coreState := CapabilityStateSupported |
| 82 | coreDetail := "Tools, prompts, resources over the core protocol" |
| 83 | if anyConnected { |
| 84 | coreState = CapabilityStateNegotiated |
| 85 | } |
| 86 | views = append(views, CapabilityView{ |
| 87 | ID: CapabilityIDCore, Layer: LayerCore, |
| 88 | State: coreState, Negotiated: anyConnected, Detail: coreDetail, |
| 89 | }) |
| 90 | |
| 91 | interactiveState := CapabilityStateUnavailable |
| 92 | interactiveDetail := "Host profile " + profile.String() + " declares no elicitation" |
| 93 | if profileCaps.ElicitationForms || profileCaps.ElicitationURL { |
| 94 | interactiveState = CapabilityStateSupported |
| 95 | interactiveDetail = "Form and URL elicitation declared (profile " + profile.String() + ")" |
| 96 | if elicitationLive { |
| 97 | interactiveState = CapabilityStateNegotiated |
| 98 | interactiveDetail = "Elicitation active on at least one compatible session" |
| 99 | } else if anyConnected { |
| 100 | interactiveDetail = "Declared; no connected session can deliver elicitation yet" |
| 101 | } |
| 102 | } |
| 103 | views = append(views, CapabilityView{ |
| 104 | ID: CapabilityIDInteractive, Layer: LayerInteractive, |
| 105 | State: interactiveState, Negotiated: elicitationLive, Detail: interactiveDetail, |
| 106 | }) |
| 107 | |
| 108 | appsState := CapabilityStateUnavailable |
| 109 | appsDetail := "Host profile " + profile.String() + " declares no Apps extension" |
| 110 | if profileCaps.AppsUI { |
| 111 | appsState = CapabilityStateSupported |
| 112 | appsDetail = "io.modelcontextprotocol/ui declared (profile " + profile.String() + ")" |
| 113 | if appsLive { |
| 114 | appsState = CapabilityStateNegotiated |
| 115 | appsDetail = "Apps extension agreed by at least one server" |
| 116 | } else if anyConnected { |
| 117 | appsDetail = "Declared; no connected server answered with the Apps extension" |
| 118 | } |
| 119 | } |
| 120 | views = append(views, CapabilityView{ |
| 121 | ID: CapabilityIDApps, Layer: LayerApps, |
| 122 | State: appsState, Negotiated: appsLive, Detail: appsDetail, |
| 123 | }) |
| 124 | return views |
| 125 | } |
| 126 | |
| 127 | // fillServerNegotiation stamps one ServerStatus with the host profile and the |
| 128 | // per-server negotiated elicitation/Apps state. |
| 129 | func fillServerNegotiation(s *ServerStatus, profile HostProfile, c *Client) { |
| 130 | s.HostProfile = profile.String() |
| 131 | s.ElicitationNegotiated = profile.Capabilities().ElicitationForms && c.elicitationUsable() |
| 132 | s.AppsNegotiated = profile.Capabilities().AppsUI && c.capabilities.appsUI() |
| 133 | } |
| 134 | |
| 135 | // snapshotClients copies the host's client list under its lock. |
| 136 | func (h *Host) snapshotClients() []*Client { |
| 137 | h.mu.RLock() |
| 138 | defer h.mu.RUnlock() |
| 139 | out := make([]*Client, len(h.clients)) |
| 140 | copy(out, h.clients) |
| 141 | return out |
| 142 | } |
| 143 | |
| 144 | // FormatCapabilityViews renders the matrix as aligned text for /mcp status. |
| 145 | func FormatCapabilityViews(views []CapabilityView) string { |
| 146 | widths := [2]int{} |
| 147 | for _, v := range views { |
| 148 | if len(v.Layer) > widths[0] { |
| 149 | widths[0] = len(v.Layer) |
| 150 | } |
| 151 | if len(v.State) > widths[1] { |
| 152 | widths[1] = len(v.State) |
| 153 | } |
| 154 | } |
| 155 | rows := make([]string, 0, len(views)) |
| 156 | for _, v := range views { |
| 157 | rows = append(rows, " "+v.Layer+strings.Repeat(" ", widths[0]-len(v.Layer)+2)+ |
| 158 | v.State+strings.Repeat(" ", widths[1]-len(v.State)+2)+v.Detail) |
| 159 | } |
| 160 | return strings.Join(rows, "\n") |
| 161 | } |
| 162 | |
| 163 | // SortCapabilityViews orders matrix rows protocol → core → interactive → apps. |
| 164 | func SortCapabilityViews(views []CapabilityView) { |
| 165 | order := map[string]int{ |
| 166 | CapabilityIDProtocol: 0, CapabilityIDCore: 1, |
| 167 | CapabilityIDInteractive: 2, CapabilityIDApps: 3, |
| 168 | } |
| 169 | sort.SliceStable(views, func(i, j int) bool { |
| 170 | return order[views[i].ID] < order[views[j].ID] |
| 171 | }) |
| 172 | } |
| 173 |