Skip to content

Commit da2fb08

Browse files
authored
Merge pull request #1872 from dgageot/mcp
Improve MCP server lifecycle: caching and auto-restart
2 parents 4b28b50 + 4795cae commit da2fb08

File tree

10 files changed

+547
-282
lines changed

10 files changed

+547
-282
lines changed

examples/mcp-toolkit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ agents:
4141
- type: mcp
4242
command: docker
4343
args: ["mcp", "gateway", "run"]
44-
tools: ["mcp-activate-profile", "mcp-add", "mcp-config-set", "mcp-create-profile", "mcp-remove"]
4544

4645
permissions:
4746
allow:
@@ -50,5 +49,6 @@ permissions:
5049
- mcp-add
5150
- mcp-config-set
5251
- mcp-create-profile
52+
- mcp-find
5353
- mcp-remove
5454

pkg/app/app.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ func New(ctx context.Context, rt runtime.Runtime, sess *session.Session, opts ..
125125
})
126126
}
127127

128+
// Subscribe to tool list changes so the sidebar updates immediately
129+
// when an MCP server adds or removes tools (outside of a RunStream).
130+
if tcs, ok := rt.(runtime.ToolsChangeSubscriber); ok {
131+
tcs.OnToolsChanged(func(event runtime.Event) {
132+
select {
133+
case app.events <- event:
134+
case <-ctx.Done():
135+
}
136+
})
137+
}
138+
128139
return app
129140
}
130141

pkg/runtime/runtime.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,15 @@ type RAGInitializer interface {
185185
StartBackgroundRAGInit(ctx context.Context, sendEvent func(Event))
186186
}
187187

188+
// ToolsChangeSubscriber is implemented by runtimes that can notify when
189+
// toolsets report a change in their tool list (e.g. after an MCP
190+
// ToolListChanged notification). The provided callback is invoked
191+
// outside of any RunStream, so the UI can update the tool count
192+
// immediately.
193+
type ToolsChangeSubscriber interface {
194+
OnToolsChanged(handler func(Event))
195+
}
196+
188197
// LocalRuntime manages the execution of agents
189198
type LocalRuntime struct {
190199
toolMap map[string]ToolHandler
@@ -209,6 +218,9 @@ type LocalRuntime struct {
209218
// fallbackCooldowns tracks per-agent cooldown state for sticky fallback behavior
210219
fallbackCooldowns map[string]*fallbackCooldownState
211220
fallbackCooldownsMux sync.RWMutex
221+
222+
// onToolsChanged is called when an MCP toolset reports a tool list change.
223+
onToolsChanged func(Event)
212224
}
213225

214226
type streamResult struct {
@@ -748,6 +760,40 @@ func (r *LocalRuntime) ResetStartupInfo() {
748760
r.startupInfoEmitted = false
749761
}
750762

763+
// OnToolsChanged registers a handler that is called when an MCP toolset
764+
// reports a tool list change outside of a RunStream. This allows the UI
765+
// to update the tool count immediately.
766+
func (r *LocalRuntime) OnToolsChanged(handler func(Event)) {
767+
r.onToolsChanged = handler
768+
769+
for _, name := range r.team.AgentNames() {
770+
a, err := r.team.Agent(name)
771+
if err != nil {
772+
continue
773+
}
774+
for _, ts := range a.ToolSets() {
775+
if n, ok := tools.As[tools.ChangeNotifier](ts); ok {
776+
n.SetToolsChangedHandler(r.emitToolsChanged)
777+
}
778+
}
779+
}
780+
}
781+
782+
// emitToolsChanged is the callback registered on MCP toolsets. It re-reads
783+
// the current agent's full tool list and pushes a ToolsetInfo event.
784+
func (r *LocalRuntime) emitToolsChanged() {
785+
if r.onToolsChanged == nil {
786+
return
787+
}
788+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
789+
defer cancel()
790+
agentTools, err := r.CurrentAgentTools(ctx)
791+
if err != nil {
792+
return
793+
}
794+
r.onToolsChanged(ToolsetInfo(len(agentTools), false, r.currentAgent))
795+
}
796+
751797
// EmitStartupInfo emits initial agent, team, and toolset information for immediate sidebar display.
752798
// When sess is non-nil and contains token data, a TokenUsageEvent is also emitted so that the
753799
// sidebar can display context usage percentage on session restore.
@@ -970,6 +1016,11 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
9701016
return
9711017
}
9721018

1019+
// Emit updated tool count. After a ToolListChanged MCP notification
1020+
// the cache is invalidated, so getTools above re-fetches from the
1021+
// server and may return a different count.
1022+
events <- ToolsetInfo(len(agentTools), false, r.currentAgent)
1023+
9731024
// Check iteration limit
9741025
if runtimeMaxIterations > 0 && iteration >= runtimeMaxIterations {
9751026
slog.Debug(

pkg/runtime/runtime_test.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ func TestSimple(t *testing.T) {
270270

271271
// Extract the actual message from MessageAddedEvent to use in comparison
272272
// (it contains dynamic fields like CreatedAt that we can't predict)
273-
require.Len(t, events, 9)
274-
msgAdded := events[6].(*MessageAddedEvent)
273+
require.Len(t, events, 10)
274+
msgAdded := events[7].(*MessageAddedEvent)
275275
require.NotNil(t, msgAdded.Message)
276276
require.Equal(t, "Hello", msgAdded.Message.Message.Content)
277277
require.Equal(t, chat.MessageRoleAssistant, msgAdded.Message.Message.Role)
@@ -282,6 +282,7 @@ func TestSimple(t *testing.T) {
282282
ToolsetInfo(0, false, "root"),
283283
UserMessage("Hi", sess.ID, nil, 0),
284284
StreamStarted(sess.ID, "root"),
285+
ToolsetInfo(0, false, "root"),
285286
AgentChoice("root", "Hello"),
286287
MessageAdded(sess.ID, msgAdded.Message, "root"),
287288
NewTokenUsageEvent(sess.ID, "root", &Usage{InputTokens: 3, OutputTokens: 2, ContextLength: 5, LastMessage: &MessageUsage{
@@ -310,8 +311,8 @@ func TestMultipleContentChunks(t *testing.T) {
310311

311312
// Extract the actual message from MessageAddedEvent to use in comparison
312313
// (it contains dynamic fields like CreatedAt that we can't predict)
313-
require.Len(t, events, 13)
314-
msgAdded := events[10].(*MessageAddedEvent)
314+
require.Len(t, events, 14)
315+
msgAdded := events[11].(*MessageAddedEvent)
315316
require.NotNil(t, msgAdded.Message)
316317

317318
expectedEvents := []Event{
@@ -320,6 +321,7 @@ func TestMultipleContentChunks(t *testing.T) {
320321
ToolsetInfo(0, false, "root"),
321322
UserMessage("Please greet me", sess.ID, nil, 0),
322323
StreamStarted(sess.ID, "root"),
324+
ToolsetInfo(0, false, "root"),
323325
AgentChoice("root", "Hello "),
324326
AgentChoice("root", "there, "),
325327
AgentChoice("root", "how "),
@@ -350,8 +352,8 @@ func TestWithReasoning(t *testing.T) {
350352

351353
// Extract the actual message from MessageAddedEvent to use in comparison
352354
// (it contains dynamic fields like CreatedAt that we can't predict)
353-
require.Len(t, events, 11)
354-
msgAdded := events[8].(*MessageAddedEvent)
355+
require.Len(t, events, 12)
356+
msgAdded := events[9].(*MessageAddedEvent)
355357
require.NotNil(t, msgAdded.Message)
356358

357359
expectedEvents := []Event{
@@ -360,6 +362,7 @@ func TestWithReasoning(t *testing.T) {
360362
ToolsetInfo(0, false, "root"),
361363
UserMessage("Hi", sess.ID, nil, 0),
362364
StreamStarted(sess.ID, "root"),
365+
ToolsetInfo(0, false, "root"),
363366
AgentChoiceReasoning("root", "Let me think about this..."),
364367
AgentChoiceReasoning("root", " I should respond politely."),
365368
AgentChoice("root", "Hello, how can I help you?"),
@@ -389,8 +392,8 @@ func TestMixedContentAndReasoning(t *testing.T) {
389392

390393
// Extract the actual message from MessageAddedEvent to use in comparison
391394
// (it contains dynamic fields like CreatedAt that we can't predict)
392-
require.Len(t, events, 12)
393-
msgAdded := events[9].(*MessageAddedEvent)
395+
require.Len(t, events, 13)
396+
msgAdded := events[10].(*MessageAddedEvent)
394397
require.NotNil(t, msgAdded.Message)
395398

396399
expectedEvents := []Event{
@@ -399,6 +402,7 @@ func TestMixedContentAndReasoning(t *testing.T) {
399402
ToolsetInfo(0, false, "root"),
400403
UserMessage("Hi there", sess.ID, nil, 0),
401404
StreamStarted(sess.ID, "root"),
405+
ToolsetInfo(0, false, "root"),
402406
AgentChoiceReasoning("root", "The user wants a greeting"),
403407
AgentChoice("root", "Hello!"),
404408
AgentChoiceReasoning("root", " I should be friendly"),
@@ -450,16 +454,17 @@ func TestErrorEvent(t *testing.T) {
450454
events = append(events, ev)
451455
}
452456

453-
require.Len(t, events, 7)
457+
require.Len(t, events, 8)
454458
require.IsType(t, &AgentInfoEvent{}, events[0])
455459
require.IsType(t, &TeamInfoEvent{}, events[1])
456460
require.IsType(t, &ToolsetInfoEvent{}, events[2])
457461
require.IsType(t, &UserMessageEvent{}, events[3])
458462
require.IsType(t, &StreamStartedEvent{}, events[4])
459-
require.IsType(t, &ErrorEvent{}, events[5])
460-
require.IsType(t, &StreamStoppedEvent{}, events[6])
463+
require.IsType(t, &ToolsetInfoEvent{}, events[5])
464+
require.IsType(t, &ErrorEvent{}, events[6])
465+
require.IsType(t, &StreamStoppedEvent{}, events[7])
461466

462-
errorEvent := events[5].(*ErrorEvent)
467+
errorEvent := events[6].(*ErrorEvent)
463468
require.Contains(t, errorEvent.Error, "simulated error")
464469
}
465470

pkg/tools/capabilities.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ func GetInstructions(ts ToolSet) string {
3434
return ""
3535
}
3636

37+
// ChangeNotifier is implemented by toolsets that can notify when their
38+
// tool list changes (e.g. after an MCP ToolListChanged notification).
39+
type ChangeNotifier interface {
40+
SetToolsChangedHandler(handler func())
41+
}
42+
3743
// ConfigureHandlers sets all applicable handlers on a toolset.
3844
// It checks for Elicitable and OAuthCapable interfaces and configures them.
3945
// This is a convenience function that handles the capability checking internally.

0 commit comments

Comments
 (0)