Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ remain conservative. Do not introduce a static custom-model declaration or new c
- verify: `TestOperatorDefinedLLMProviders_Scenario4_ListingFallback`
- AC4.4: unknown live model metadata never overclaims image, audio, reasoning, output, or
context-window capabilities.
- verify: `TestInvariant_custom_provider_live_metadata_conservative`
- verify: `TestInvariant_custom_provider_omitted_live_modalities_use_adapter`
15 changes: 9 additions & 6 deletions cmd/mecatui/client/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ type Capabilities struct {
// MCPConnectorStatus gates the broker-local connector inventory. It is separate
// from MCP: broker-only deployments deliberately expose no direct resources or prompts.
MCPConnectorStatus bool
MCP bool
SlashCommands bool
Memory bool
Skills bool
Teams bool
Agents bool
// MCPRefresh gates explicit direct/global source reconciliation.
MCPRefresh bool
MCP bool
SlashCommands bool
Memory bool
Skills bool
Teams bool
Agents bool
// Bash reports availability of the canonical Shell tool. Its historical
// spelling is retained for compatibility with the established wire/Go API.
Bash bool
Expand Down Expand Up @@ -108,6 +110,7 @@ func capabilitiesFrom(c *mecatlv1.ServerCapabilities) Capabilities {
}
return Capabilities{
MCPConnectorStatus: c.GetMcpConnectorStatus(),
MCPRefresh: c.GetMcpRefresh(),
MCP: c.GetMcp(),
SlashCommands: c.GetSlashCommands(),
Memory: c.GetMemory(),
Expand Down
69 changes: 62 additions & 7 deletions cmd/mecatui/client/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,7 @@ type MCPServerInfo struct {
Group string
}

// MCPSource is one inventory source — a ToolHive group or config block — with its
// servers and any diagnostics (proto McpSource). NOTE: ListMcpSources reflects a
// startup snapshot; servers started AFTER mecated launched won't appear.
// MCPSource is one cached published/pre-shadow source inventory row.
type MCPSource struct {
Name string
Kind string
Expand Down Expand Up @@ -160,7 +158,24 @@ type MCPPromptGotMsg struct {

// MCPSourcesMsg carries a ListMcpSources success (the panel inventory).
type MCPSourcesMsg struct {
Sources []MCPSource
Sources []MCPSource
Revision uint64
Stale bool
Reconciling bool
}

// MCPRefreshResult identifies the direct runtime considered by one refresh.
type MCPRefreshResult struct {
Revision uint64
Changed bool
}

// MCPRefreshMsg carries an explicit direct-source refresh result.
type MCPRefreshMsg struct {
RequestToken uint64
SessionID string
Result MCPRefreshResult
Err error
}

// MCPGroupsMsg carries a ListToolHiveGroups success.
Expand Down Expand Up @@ -262,13 +277,28 @@ func (c *Client) GetMCPPrompt(ctx context.Context, server, name string, args map
return resp.GetDescription(), mapPromptMessages(resp.GetMessages()), nil
}

// ListMCPSources lists the inventory sources (the panel snapshot).
// ListMCPSources lists the cached inventory sources.
func (c *Client) ListMCPSources(ctx context.Context) ([]MCPSource, error) {
result, err := c.ListMCPSourceStatus(ctx)
return result.Sources, err
}

// ListMCPSourceStatus returns cached inventory plus publication status.
func (c *Client) ListMCPSourceStatus(ctx context.Context) (MCPSourcesMsg, error) {
resp, err := c.svc.ListMcpSources(ctx, &mecatlv1.ListMcpSourcesRequest{})
if err != nil {
return nil, err
return MCPSourcesMsg{}, err
}
return mapSources(resp.GetSources()), nil
return MCPSourcesMsg{Sources: mapSources(resp.GetSources()), Revision: resp.GetRevision(), Stale: resp.GetStale(), Reconciling: resp.GetReconciling()}, nil
}

// RefreshMCP explicitly reconciles direct MCP sources for one session.
func (c *Client) RefreshMCP(ctx context.Context, sessionID string) (MCPRefreshResult, error) {
resp, err := c.svc.RefreshMcpSources(ctx, &mecatlv1.RefreshMcpSourcesRequest{SessionId: sessionID})
if err != nil {
return MCPRefreshResult{}, err
}
return MCPRefreshResult{Revision: resp.GetRevision(), Changed: resp.GetChanged()}, nil
}

// ListToolHiveGroups lists the configured ToolHive group names.
Expand Down Expand Up @@ -412,6 +442,16 @@ type MCPConnectorReader interface {
ListMCPConnectors(ctx context.Context, sessionID string) (MCPConnectorInventory, error)
}

// MCPRefresher is the optional explicit direct-source control.
type MCPRefresher interface {
RefreshMCP(ctx context.Context, sessionID string) (MCPRefreshResult, error)
}

// MCPSourceStatusReader is the optional cached status extension.
type MCPSourceStatusReader interface {
ListMCPSourceStatus(ctx context.Context) (MCPSourcesMsg, error)
}

// ListMcpResourcesCmd lists resources (server "" = all).
func ListMcpResourcesCmd(ctx context.Context, m MCP, server string) tea.Cmd {
return func() tea.Msg {
Expand Down Expand Up @@ -459,6 +499,13 @@ func GetMcpPromptCmd(ctx context.Context, m MCP, server, name string, args map[s
// ListMcpSourcesCmd lists the inventory sources (the panel snapshot).
func ListMcpSourcesCmd(ctx context.Context, m MCP) tea.Cmd {
return func() tea.Msg {
if statusReader, ok := m.(MCPSourceStatusReader); ok {
status, err := statusReader.ListMCPSourceStatus(ctx)
if err != nil {
return MCPErrMsg{Op: "list sources", Class: classifyMCPErr(err), Err: err}
}
return status
}
s, err := m.ListMCPSources(ctx)
if err != nil {
return MCPErrMsg{Op: "list sources", Class: classifyMCPErr(err), Err: err}
Expand All @@ -467,6 +514,14 @@ func ListMcpSourcesCmd(ctx context.Context, m MCP) tea.Cmd {
}
}

// RefreshMcpSourcesCmd invokes explicit direct-source refresh.
func RefreshMcpSourcesCmd(ctx context.Context, m MCPRefresher, sessionID string, requestToken uint64) tea.Cmd {
return func() tea.Msg {
result, err := m.RefreshMCP(ctx, sessionID)
return MCPRefreshMsg{RequestToken: requestToken, SessionID: sessionID, Result: result, Err: err}
}
}

// ListToolHiveGroupsCmd lists the configured ToolHive groups.
func ListToolHiveGroupsCmd(ctx context.Context, m MCP) tea.Cmd {
return func() tea.Msg {
Expand Down
15 changes: 13 additions & 2 deletions cmd/mecatui/ui/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type builtin struct {
// read clearly and a new collaborator is one field, not an 8th positional bool.
type wiredCollaborators struct {
MCP bool
MCPRefresh bool
MCPConnector bool
Agents bool
Skills bool
Expand Down Expand Up @@ -58,8 +59,9 @@ type wiredCollaborators struct {
// though the actual dispatch path built it correctly).
func (m Model) wiredCollaborators() wiredCollaborators {
_, mcpConnector := m.deps.MCP.(client.MCPConnectorReader)
_, mcpRefresh := m.deps.MCP.(client.MCPRefresher)
return wiredCollaborators{
MCP: m.deps.MCP != nil, MCPConnector: mcpConnector,
MCP: m.deps.MCP != nil, MCPRefresh: mcpRefresh, MCPConnector: mcpConnector,
Agents: m.deps.Agents != nil, Skills: m.deps.Skills != nil,
Soul: m.deps.Soul != nil, UserModel: m.deps.UserModel != nil, Models: m.deps.Models != nil,
Reflections: m.deps.Reflections != nil,
Expand Down Expand Up @@ -154,6 +156,15 @@ func builtinCommands(caps client.Capabilities, w wiredCollaborators) []builtin {
run: Model.runMCP,
})
}
directRefresh := caps.MCPRefresh && !caps.WorkspaceEnrollment && w.MCPRefresh
brokerRefresh := caps.WorkspaceEnrollment && !caps.MCPRefresh && w.Workspace
if directRefresh || brokerRefresh {
out = append(out, builtin{
name: "mcp-refresh",
desc: "refresh MCP tools for this session",
run: Model.runMCPRefresh,
})
}
if caps.Agents && w.Agents {
out = append(out, builtin{
name: "agents",
Expand Down Expand Up @@ -241,7 +252,7 @@ func builtinCommands(caps client.Capabilities, w wiredCollaborators) []builtin {
}
if caps.WorkspaceEnrollment && w.Workspace {
out = append(out,
builtin{name: "tools-connect", desc: "connect the bundled protected-tool workspace services", run: Model.runToolsConnect},
builtin{name: "tools-connect", desc: "deprecated alias for /mcp-refresh in broker mode", run: Model.runToolsConnect},
builtin{name: "tools-cancel", desc: "cancel a pending workspace-services connection", run: Model.runToolsCancel},
)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/ui/builtins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func TestBuiltinCommandsCapsFilter(t *testing.T) {
{"sessions not wired", client.Capabilities{}, wiredCollaborators{}, []string{"clear", "help"}},
{"workspace enrollment cap but not wired", client.Capabilities{WorkspaceEnrollment: true}, wiredCollaborators{}, []string{"clear", "help"}},
{"workspace enrollment wired but no cap", client.Capabilities{}, wiredCollaborators{Workspace: true}, []string{"clear", "help"}},
{"workspace enrollment cap and wired", client.Capabilities{WorkspaceEnrollment: true}, wiredCollaborators{Workspace: true}, []string{"clear", "help", "tools-connect", "tools-cancel"}},
{"workspace enrollment cap and wired", client.Capabilities{WorkspaceEnrollment: true}, wiredCollaborators{Workspace: true}, []string{"clear", "help", "mcp-refresh", "tools-connect", "tools-cancel"}},
{"posture empty omits the builtin", client.Capabilities{}, wiredCollaborators{}, []string{"clear", "help"}},
{"posture set adds the builtin", client.Capabilities{Posture: "yolo"}, wiredCollaborators{}, []string{"clear", "help", "posture"}},
{"posture strict still shows (chrome is reportable)", client.Capabilities{Posture: "strict"}, wiredCollaborators{}, []string{"clear", "help", "posture"}},
Expand Down
6 changes: 3 additions & 3 deletions cmd/mecatui/ui/keymark_liveness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,10 +726,10 @@ func TestMCPOverlayHintsReflectKeyOverride(t *testing.T) {
}
t.Run("panel footer", func(t *testing.T) {
got := render(mcpState{view: mcpPanel})
if !strings.Contains(got, "ctrl+f24 refresh · ctrl+f16 close") {
t.Errorf("panel footer should carry live refresh+close: %q", got)
if !strings.Contains(got, "ctrl+f24 reload status · ctrl+f16 close") {
t.Errorf("panel footer should carry live status-reload+close: %q", got)
}
if strings.Contains(got, "r refresh · esc close") {
if strings.Contains(got, "r reload status · esc close") {
t.Errorf("panel footer still shows defaults: %q", got)
}
})
Expand Down
59 changes: 45 additions & 14 deletions cmd/mecatui/ui/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ func (m Model) runMCP() (tea.Model, tea.Cmd) { return m.openMCP(mcpPane
func (m Model) runMCPResources() (tea.Model, tea.Cmd) { return m.openMCP(mcpResources) }
func (m Model) runMCPPrompts() (tea.Model, tea.Cmd) { return m.openMCP(mcpPrompts) }

func (m Model) runMCPRefresh() (tea.Model, tea.Cmd) {
direct := m.caps.MCPRefresh
broker := m.caps.WorkspaceEnrollment
if direct == broker {
m.statusMsg = m.deps.Theme.Style("warning").Render("MCP refresh is unavailable for this server mode")
return m, nil
}
if broker {
if m.deps.WorkspaceEnrollment == nil {
m.statusMsg = m.deps.Theme.Style("warning").Render("MCP refresh collaborator is unavailable")
return m, nil
}
return m.runToolsConnect()
}
refresher, ok := m.deps.MCP.(client.MCPRefresher)
if !ok || refresher == nil || m.sessionID == "" || m.phase != phaseIdle {
m.statusMsg = m.deps.Theme.Style("warning").Render("MCP refresh is available only for an idle active session")
return m, nil
}
m.statusMsg = m.deps.Theme.Style("muted").Render("refreshing MCP tools…")
m.mcpRefreshRequestToken++
return m, client.RefreshMcpSourcesCmd(m.deps.Ctx, refresher, m.sessionID, m.mcpRefreshRequestToken)
}

// openMCP opens the selected MCP surface and starts its initial RPC.
func (m Model) openMCP(v mcpView) (tea.Model, tea.Cmd) {
if m.phase != phaseIdle || m.deps.MCP == nil || (v != mcpPanel && !m.caps.MCP) {
Expand Down Expand Up @@ -81,10 +105,13 @@ type mcpState struct {
errCls client.MCPErrorClass

// Inventory panel.
sources []client.MCPSource
groups []string // ToolHive groups (best-effort; see groupsErr)
groupsErr bool // the groups fetch failed — degrade quietly, panel still works
groupsDone bool // a groups result (success or error) has arrived
sources []client.MCPSource
revision uint64
stale bool
reconciling bool
groups []string // ToolHive groups (best-effort; see groupsErr)
groupsErr bool // the groups fetch failed — degrade quietly, panel still works
groupsDone bool // a groups result (success or error) has arrived

// Broker-only panel state. It is intentionally separate from direct MCP sources:
// broker capability must never trigger direct source/resource/prompt/group RPCs.
Expand Down Expand Up @@ -472,6 +499,9 @@ func (s *mcpState) HandleMsg(msg tea.Msg) (cmd tea.Cmd, handled bool, closed boo
s.refreshed = true // panel now shows live-re-probed state, not the startup snapshot
}
s.sources = msg.Sources
s.revision = msg.Revision
s.stale = msg.Stale
s.reconciling = msg.Reconciling
return nil, true, false
case client.MCPGroupsMsg:
s.groups = msg.Groups
Expand Down Expand Up @@ -783,21 +813,22 @@ func brokerCatalogueLabel(state string) string {
}
}

// mcpPanelFooter is the panel's footer hint. Before any manual refresh it carries
// the startup-snapshot caveat; after a successful re-probe it reads "updated" so
// the user knows the panel reflects LIVE source status. Both forms advertise the
// r-refresh and esc-close keys, sourced from the LIVE Refresh/Close markings
// (issue #457). No wall-clock — the wording is state-driven so the View stays
// golden-stable.
// mcpPanelFooter reports cached reconciler status. The r key reloads that cache;
// explicit direct/broker mutation is the separate /mcp-refresh command.
func mcpPanelFooter(st mcpState, hk helpKeys) string {
refreshClose := hk.refresh + " refresh · " + hk.closeOnly + " close"
refreshClose := hk.refresh + " reload status · " + hk.closeOnly + " close"
prefix := fmt.Sprintf("revision %d · cached", st.revision)
switch {
case st.refreshing:
return "refreshing… · " + refreshClose
return "reloading status… · " + refreshClose
case st.reconciling:
return prefix + " · reconciling… · " + refreshClose
case st.stale:
return prefix + " · stale · " + refreshClose
case st.refreshed:
return "updated — live MCP source status · " + refreshClose
return prefix + " · updated · " + refreshClose
default:
return "snapshot from mecated startup — servers started later won't appear · " + refreshClose
return prefix + " · " + refreshClose
}
}

Expand Down
Loading
Loading