Skip to content

Commit 01a8b20

Browse files
authored
Merge pull request #2512 from dgageot/oauth-slack
Make the slack remote MCP server work
2 parents 3b7f47e + 1229056 commit 01a8b20

13 files changed

Lines changed: 1367 additions & 48 deletions

File tree

pkg/agent/agent.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ type Agent struct {
4444
hooks *latest.HooksConfig
4545
cache *cache.Cache
4646

47-
// warningsMu guards pendingWarnings. addToolWarning and DrainWarnings
48-
// may be called concurrently from the runtime loop, the MCP server,
49-
// the TUI and session manager.
47+
// warningsMu guards pendingWarnings and pendingNotices. AddToolWarning,
48+
// AddToolNotice, DrainWarnings and DrainNotices may be called
49+
// concurrently from the runtime loop, the MCP server, the TUI and
50+
// session manager.
5051
warningsMu sync.Mutex
5152
pendingWarnings []string
53+
pendingNotices []string
5254
}
5355

5456
// New creates a new agent
@@ -288,7 +290,7 @@ func (a *Agent) collectTools(ctx context.Context) ([]tools.Tool, error) {
288290
if err != nil {
289291
desc := tools.DescribeToolSet(toolSet)
290292
slog.Warn("Toolset listing failed; skipping", "agent", a.Name(), "toolset", desc, "error", err)
291-
a.addToolWarning(fmt.Sprintf("%s list failed: %v", desc, err))
293+
a.AddToolWarning(fmt.Sprintf("%s list failed: %v", desc, err))
292294
continue
293295
}
294296
agentTools = append(agentTools, ta...)
@@ -321,7 +323,7 @@ func (a *Agent) ensureToolSetsAreStarted(ctx context.Context) {
321323
if toolSet.ShouldReportFailure() {
322324
desc := tools.DescribeToolSet(toolSet)
323325
slog.Warn("Toolset start failed; will retry on next turn", "agent", a.Name(), "toolset", desc, "error", err)
324-
a.addToolWarning(fmt.Sprintf("%s start failed: %v", desc, err))
326+
a.AddToolWarning(fmt.Sprintf("%s start failed: %v", desc, err))
325327
} else {
326328
desc := tools.DescribeToolSet(toolSet)
327329
slog.Debug("Toolset still unavailable; retrying next turn", "agent", a.Name(), "toolset", desc, "error", err)
@@ -332,13 +334,17 @@ func (a *Agent) ensureToolSetsAreStarted(ctx context.Context) {
332334
if toolSet.ConsumeRecovery() {
333335
desc := tools.DescribeToolSet(toolSet)
334336
slog.Info("Toolset now available", "agent", a.Name(), "toolset", desc)
335-
a.addToolWarning(desc + " is now available")
337+
a.AddToolNotice(desc + " is now available")
336338
}
337339
}
338340
}
339341

340-
// addToolWarning records a warning generated while loading or starting toolsets.
341-
func (a *Agent) addToolWarning(msg string) {
342+
// AddToolWarning records a warning generated while loading or starting toolsets.
343+
// Warnings represent real failures the user should know about (a remote MCP
344+
// server returning 4xx, an MCP binary missing, ...). For positive notices
345+
// (a previously-failed toolset becoming available again) use AddToolNotice
346+
// instead so the message isn't framed as a failure.
347+
func (a *Agent) AddToolWarning(msg string) {
342348
if msg == "" {
343349
return
344350
}
@@ -347,6 +353,19 @@ func (a *Agent) addToolWarning(msg string) {
347353
a.warningsMu.Unlock()
348354
}
349355

356+
// AddToolNotice records a positive, informational notice about a toolset
357+
// (typically: a previously-failed toolset is now available). Notices are
358+
// surfaced to the user separately from warnings so the framing doesn't
359+
// say "failed to initialize" for a recovery message.
360+
func (a *Agent) AddToolNotice(msg string) {
361+
if msg == "" {
362+
return
363+
}
364+
a.warningsMu.Lock()
365+
a.pendingNotices = append(a.pendingNotices, msg)
366+
a.warningsMu.Unlock()
367+
}
368+
350369
// DrainWarnings returns pending warnings and clears them.
351370
func (a *Agent) DrainWarnings() []string {
352371
a.warningsMu.Lock()
@@ -356,6 +375,15 @@ func (a *Agent) DrainWarnings() []string {
356375
return warnings
357376
}
358377

378+
// DrainNotices returns pending notices and clears them.
379+
func (a *Agent) DrainNotices() []string {
380+
a.warningsMu.Lock()
381+
defer a.warningsMu.Unlock()
382+
notices := a.pendingNotices
383+
a.pendingNotices = nil
384+
return notices
385+
}
386+
359387
func (a *Agent) StopToolSets(ctx context.Context) error {
360388
for _, toolSet := range a.toolsets {
361389
// Only stop toolsets that were successfully started

pkg/agent/agent_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -370,21 +370,23 @@ func TestAgentReProbeEmitsWarningThenNotice(t *testing.T) {
370370
}
371371
a := New("root", "test", WithToolSets(stub))
372372

373-
// Turn 1: start fails → 1 warning, 0 tools.
373+
// Turn 1: start fails → 1 warning, 0 tools, no notice yet.
374374
got, err := a.Tools(t.Context())
375375
require.NoError(t, err)
376376
assert.Empty(t, got, "turn 1: no tools while toolset is unavailable")
377377
warnings := a.DrainWarnings()
378378
require.Len(t, warnings, 1, "turn 1: exactly one warning expected")
379379
assert.Contains(t, warnings[0], "start failed")
380+
assert.Empty(t, a.DrainNotices(), "turn 1: no recovery notice while still failing")
380381

381-
// Turn 2: start succeeds → 1 recovery warning, tools available.
382+
// Turn 2: start succeeds → 1 recovery NOTICE (not warning), tools available.
382383
got, err = a.Tools(t.Context())
383384
require.NoError(t, err)
384385
assert.Len(t, got, 1, "turn 2: tool should be available after recovery")
385-
recovery := a.DrainWarnings()
386-
require.Len(t, recovery, 1, "turn 2: exactly one recovery warning expected")
387-
assert.Contains(t, recovery[0], "now available", "turn 2: recovery warning must mention availability")
386+
assert.Empty(t, a.DrainWarnings(), "turn 2: recovery is a notice, not a failure warning")
387+
notices := a.DrainNotices()
388+
require.Len(t, notices, 1, "turn 2: exactly one recovery notice expected")
389+
assert.Contains(t, notices[0], "now available", "turn 2: recovery notice must mention availability")
388390
}
389391

390392
// TestAgentNoDuplicateStartWarnings verifies that repeated failures generate
@@ -435,7 +437,7 @@ func TestAgentWarningsConcurrentAccess(t *testing.T) {
435437
go func() {
436438
defer wg.Done()
437439
for range perWriter {
438-
a.addToolWarning("boom")
440+
a.AddToolWarning("boom")
439441
}
440442
}()
441443
}

pkg/agent/opts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func WithCommands(commands types.Commands) Opt {
163163
func WithLoadTimeWarnings(warnings []string) Opt {
164164
return func(a *Agent) {
165165
for _, w := range warnings {
166-
a.addToolWarning(w)
166+
a.AddToolWarning(w)
167167
}
168168
}
169169
}

pkg/runtime/loop.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -685,15 +685,23 @@ func (r *LocalRuntime) configureToolsetHandlers(a *agent.Agent, events chan Even
685685
}
686686
}
687687

688-
// emitAgentWarnings drains and emits any pending toolset warnings as persistent
689-
// TUI notifications. Both start failures and recovery notices are emitted as
690-
// warnings so they remain visible until the user dismisses them.
688+
// emitAgentWarnings drains and emits any pending toolset warnings and notices
689+
// as persistent TUI notifications. Failures ("start failed", "list failed")
690+
// and recoveries ("is now available") flow through separate queues on the
691+
// agent so each can be framed correctly — a single mixed message saying
692+
// "Some toolsets failed to initialize ... is now available" reads as a
693+
// contradiction.
691694
func (r *LocalRuntime) emitAgentWarnings(a *agent.Agent, send func(Event)) {
692695
warnings := a.DrainWarnings()
693696
if len(warnings) > 0 {
694697
slog.Warn("Tool setup partially failed; continuing", "agent", a.Name(), "warnings", warnings)
695698
send(Warning(formatToolWarning(a, warnings), a.Name()))
696699
}
700+
notices := a.DrainNotices()
701+
if len(notices) > 0 {
702+
slog.Info("Toolset status update", "agent", a.Name(), "notices", notices)
703+
send(Warning(formatToolNotice(a, notices), a.Name()))
704+
}
697705
}
698706

699707
func formatToolWarning(a *agent.Agent, warnings []string) string {
@@ -705,6 +713,15 @@ func formatToolWarning(a *agent.Agent, warnings []string) string {
705713
return strings.TrimSuffix(builder.String(), "\n")
706714
}
707715

716+
func formatToolNotice(a *agent.Agent, notices []string) string {
717+
var builder strings.Builder
718+
fmt.Fprintf(&builder, "Toolset status update for agent '%s':\n\n", a.Name())
719+
for _, notice := range notices {
720+
fmt.Fprintf(&builder, "- %s\n", notice)
721+
}
722+
return strings.TrimSuffix(builder.String(), "\n")
723+
}
724+
708725
// filterExcludedTools removes tools whose names appear in the excluded list.
709726
// This is used by skill sub-sessions to prevent recursive run_skill calls.
710727
func filterExcludedTools(agentTools []tools.Tool, excluded []string) []tools.Tool {

pkg/runtime/runtime.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -848,12 +848,23 @@ func (r *LocalRuntime) EmitStartupInfo(ctx context.Context, sess *session.Sessio
848848
send(NewTokenUsageEvent(sess.ID, r.CurrentAgentName(), usage))
849849
}
850850

851-
// Emit agent warnings (if any) - these are quick
851+
// Tool loading can be slow (MCP servers need to start). Mark the
852+
// context as non-interactive so toolsets that require user-driven
853+
// flows (e.g. an OAuth elicitation for a remote MCP server) fail
854+
// fast with a recognisable error rather than blocking on a dialog
855+
// the TUI is not yet ready to render. The actual prompt happens on
856+
// the first RunStream when the user is interacting with the agent.
857+
nonInteractiveCtx := mcptools.WithoutInteractivePrompts(ctx)
858+
r.emitToolsProgressively(nonInteractiveCtx, a, send)
859+
860+
// Flush any agent warnings: load-time warnings recorded at agent
861+
// construction (WithLoadTimeWarnings) and per-toolset warnings recorded
862+
// during startup above (e.g. a remote MCP server returning 4xx during
863+
// initialize). Surfacing them as WarningEvents lets the TUI show a
864+
// persistent notice with the actual server-side explanation — otherwise
865+
// the user only sees the toolset disappear from the sidebar with no clue
866+
// as to why.
852867
r.emitAgentWarnings(a, func(e Event) { send(e) })
853-
854-
// Tool loading can be slow (MCP servers need to start)
855-
// Emit progressive updates as each toolset loads
856-
r.emitToolsProgressively(ctx, a, send)
857868
}
858869

859870
// emitToolsProgressively loads tools from each toolset and emits progress updates.
@@ -888,7 +899,35 @@ func (r *LocalRuntime) emitToolsProgressively(ctx context.Context, a *agent.Agen
888899
if startable, ok := toolset.(*tools.StartableToolSet); ok {
889900
if !startable.IsStarted() {
890901
if err := startable.Start(ctx); err != nil {
891-
slog.Warn("Toolset start failed; skipping", "agent", a.Name(), "toolset", fmt.Sprintf("%T", startable.ToolSet), "error", err)
902+
desc := tools.DescribeToolSet(startable.ToolSet)
903+
// IsAuthorizationRequired must be checked BEFORE
904+
// ShouldReportFailure: this is the first — expected —
905+
// failure of a deferred-OAuth toolset, and consuming
906+
// freshFailure here would suppress the *real*
907+
// failure (e.g. server 4xx on the eventual interactive
908+
// retry) that the user actually needs to see.
909+
if mcptools.IsAuthorizationRequired(err) {
910+
// The toolset just needs an OAuth approval that we
911+
// deliberately deferred until the user is interacting
912+
// with the agent. The dialog will appear naturally on
913+
// the first RunStream — no need to pre-announce it.
914+
slog.Debug("Toolset deferred until first message", "agent", a.Name(), "toolset", desc, "reason", err)
915+
continue
916+
}
917+
// Route real failures through the agent's warning
918+
// channel so the TUI surfaces a persistent,
919+
// user-visible notice that includes the actual
920+
// server-side cause (threaded through by
921+
// remoteMCPClient.Initialize). Use the same
922+
// once-per-streak guard as ensureToolSetsAreStarted
923+
// so a failing toolset doesn't flood the UI with a
924+
// new warning every time the agent is restarted.
925+
if !startable.ShouldReportFailure() {
926+
slog.Debug("Toolset still unavailable; skipping", "agent", a.Name(), "toolset", desc, "error", err)
927+
continue
928+
}
929+
slog.Warn("Toolset start failed; skipping", "agent", a.Name(), "toolset", desc, "error", err)
930+
a.AddToolWarning(fmt.Sprintf("%s start failed: %v", desc, err))
892931
continue
893932
}
894933
}

0 commit comments

Comments
 (0)