fix(mcp): validate tasksMode and inherit task options in Subagent (#21)

Address two review findings on the MCP Tasks PR.

- Config.Validate() now rejects unknown tasksMode values with a clear
  error naming the server and bad value. Without this a typo (e.g.
  "alwasy") was silently downgraded to "auto" by the runtime parser.
- Kit.Subagent() now propagates the parent's six MCP task options
  (mode map, timeout, TTL, poll interval, max poll interval, progress
  callback) onto the child via a new inheritMCPTaskOptions helper.
  Without this, child subagents always saw default polling and no
  progress feedback regardless of parent configuration.

The propagation logic lives in a helper so the test exercises the real
code path instead of duplicating it; future task fields only need to be
added in one place.
This commit is contained in:
Ed Zynda
2026-05-04 17:06:11 +03:00
parent 92eaaf6a59
commit 6e36053856
5 changed files with 123 additions and 0 deletions
+11
View File
@@ -345,6 +345,17 @@ func (c *Config) Validate() error {
return fmt.Errorf("server %s: allowedTools and excludedTools are mutually exclusive", serverName)
}
// Reject unknown tasksMode values up front so a typo (e.g. "alwasy")
// fails loud here instead of being silently downgraded to "auto" by
// the runtime parser. Comparison is case-insensitive to match
// tools.ParseTaskMode.
switch strings.ToLower(strings.TrimSpace(serverConfig.TasksMode)) {
case "", "auto", "never", "always":
// ok
default:
return fmt.Errorf("server %s: invalid tasksMode %q (expected one of: auto, never, always)", serverName, serverConfig.TasksMode)
}
transport := serverConfig.GetTransportType()
switch transport {
case "stdio":
+44
View File
@@ -672,3 +672,47 @@ func TestMCPServerConfig_TasksMode_DefaultEmpty(t *testing.T) {
t.Errorf("expected default TasksMode to be empty, got %q", cfg.TasksMode)
}
}
func TestConfig_Validate_TasksMode(t *testing.T) {
t.Run("empty is valid", func(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"a": {Type: "remote", URL: "https://x.example"},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("empty TasksMode should validate, got %v", err)
}
})
t.Run("known values are valid", func(t *testing.T) {
for _, mode := range []string{"auto", "never", "always", "AUTO", " always "} {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"a": {Type: "remote", URL: "https://x.example", TasksMode: mode},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("TasksMode=%q should validate, got %v", mode, err)
}
}
})
t.Run("typo is rejected with a clear error", func(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"buildbot": {Type: "remote", URL: "https://x.example", TasksMode: "alwasy"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("expected validation error for invalid TasksMode")
}
// Error must mention the server name AND the bad value so the
// user knows where to look.
msg := err.Error()
if !strings.Contains(msg, "buildbot") || !strings.Contains(msg, `"alwasy"`) {
t.Errorf("error %q should mention both server name and bad value", msg)
}
})
}
+7
View File
@@ -1842,6 +1842,13 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
Streaming: true,
MCPConfig: m.mcpConfig,
}
// Propagate the parent's MCP task configuration so a child subagent
// invoking long-running MCP tools observes the same per-server modes,
// timeouts, and progress callback as the parent. Without this, child
// agents would silently fall back to MCPTaskModeAuto with default
// polling and no progress feedback even when the parent had configured
// custom values.
inheritMCPTaskOptions(childOpts, m.opts)
child, err := New(ctx, childOpts)
if err != nil {
return &SubagentResult{Elapsed: time.Since(start)}, fmt.Errorf("failed to create subagent: %w", err)
+16
View File
@@ -218,3 +218,19 @@ func mcpTaskFromInternal(t tools.MCPTaskInfo) MCPTask {
PollInterval: t.PollInterval,
}
}
// inheritMCPTaskOptions copies every MCP task-related field from parent
// onto child. Used by Kit.Subagent so child instances observe the same
// per-server modes, timeouts, and progress callback as their parent.
// A nil parent is a no-op so callers don't have to guard at the call site.
func inheritMCPTaskOptions(child, parent *Options) {
if child == nil || parent == nil {
return
}
child.MCPTaskMode = parent.MCPTaskMode
child.MCPTaskTimeout = parent.MCPTaskTimeout
child.MCPTaskTTL = parent.MCPTaskTTL
child.MCPTaskPollInterval = parent.MCPTaskPollInterval
child.MCPTaskMaxPollInterval = parent.MCPTaskMaxPollInterval
child.MCPTaskProgress = parent.MCPTaskProgress
}
+45
View File
@@ -118,3 +118,48 @@ func TestKitMCPTasksWithoutAgentReturnsError(t *testing.T) {
t.Error("CancelMCPTask on nil Kit should error")
}
}
func TestSubagentPropagatesMCPTaskOptions(t *testing.T) {
// Exercises the helper Kit.Subagent uses to copy MCP task options
// onto child Options. Calling the real helper (rather than
// duplicating its body in the test) means any new field added to
// the propagation list is picked up automatically by the
// equivalence assertion below.
parent := &Options{
MCPTaskMode: map[string]MCPTaskMode{
"build": MCPTaskModeAlways,
"chat": MCPTaskModeNever,
},
MCPTaskTimeout: 30 * time.Minute,
MCPTaskTTL: 45 * time.Minute,
MCPTaskPollInterval: 750 * time.Millisecond,
MCPTaskMaxPollInterval: 4 * time.Second,
MCPTaskProgress: func(MCPTaskProgress) {},
}
child := &Options{}
inheritMCPTaskOptions(child, parent)
if child.MCPTaskMode["build"] != MCPTaskModeAlways || child.MCPTaskMode["chat"] != MCPTaskModeNever {
t.Errorf("MCPTaskMode not propagated: got %+v", child.MCPTaskMode)
}
if child.MCPTaskTimeout != 30*time.Minute {
t.Errorf("MCPTaskTimeout = %v, want 30m", child.MCPTaskTimeout)
}
if child.MCPTaskTTL != 45*time.Minute {
t.Errorf("MCPTaskTTL = %v, want 45m", child.MCPTaskTTL)
}
if child.MCPTaskPollInterval != 750*time.Millisecond {
t.Errorf("MCPTaskPollInterval = %v, want 750ms", child.MCPTaskPollInterval)
}
if child.MCPTaskMaxPollInterval != 4*time.Second {
t.Errorf("MCPTaskMaxPollInterval = %v, want 4s", child.MCPTaskMaxPollInterval)
}
if child.MCPTaskProgress == nil {
t.Error("MCPTaskProgress not propagated")
}
// Nil parent is a no-op rather than a panic.
inheritMCPTaskOptions(&Options{}, nil)
inheritMCPTaskOptions(nil, parent)
}